refactor(ai): unify OpenAI-compatible completion stream processing (#129962)

* refactor(ai): unify OpenAI-compatible completion stream processing

* refactor(ai): reuse validated completion delta fields

* refactor(ai): type encrypted reasoning with canonical tool calls
This commit is contained in:
Peter Steinberger
2026-08-26 01:11:39 -07:00
committed by GitHub
parent d57a5d9900
commit f8e5674cc3
6 changed files with 288 additions and 543 deletions
+1 -1
View File
@@ -1518,7 +1518,7 @@ packages/ai/src/providers/mistral.ts 6
packages/ai/src/providers/openai-chatgpt-responses-protocol.ts 1
packages/ai/src/providers/openai-chatgpt-responses.ts 27
packages/ai/src/providers/openai-completions-tool-calls.ts 5
packages/ai/src/providers/openai-completions.ts 16
packages/ai/src/providers/openai-completions.ts 10
packages/ai/src/providers/openai-reasoning-effort.ts 6
packages/ai/src/providers/openai-responses-shared.ts 7
packages/ai/src/providers/openai-responses-tools.ts 2
-1
View File
@@ -282,7 +282,6 @@ packages/ai/src/providers/google-shared.ts
packages/ai/src/providers/mistral.ts
packages/ai/src/providers/openai-chatgpt-responses.ts
packages/ai/src/providers/openai-completions.test.ts
packages/ai/src/providers/openai-completions.ts
packages/ai/src/providers/openai-responses-shared.test.ts
packages/ai/src/transports/anthropic-transport-stream.test.ts
packages/ai/src/transports/anthropic-transport-stream.ts
@@ -3,6 +3,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { ChatCompletionChunk } from "openai/resources/chat/completions.js";
import { measureUtf8AppendBytes } from "../transports/openai-transport-shared.js";
import { finalizeTerminalToolCallArguments } from "../transports/transport-stream-shared.js";
import type { ToolCall } from "../types.js";
type ChatCompletionToolCallDelta = ChatCompletionChunk.Choice.Delta.ToolCall;
const MAX_BUFFERED_TOOL_CALL_ARGUMENT_BYTES = 256_000;
@@ -19,6 +20,49 @@ type OpenAICompletionsToolCallFinalizationOptions<TBlock extends object> = {
onConfirmedToolCall?: (block: TBlock, contentIndex: number) => void;
};
/** Keep encrypted provider reasoning attached to the first matching tool call. */
export function createOpenAIEncryptedToolCallReasoningTracker() {
const firstBlocks = new Map<string, ToolCall>();
const pendingDetails = new Map<string, string>();
return {
rememberToolCall(id: string, block: ToolCall) {
if (!id || firstBlocks.has(id)) {
return;
}
firstBlocks.set(id, block);
const pendingDetail = pendingDetails.get(id);
if (pendingDetail) {
block.thoughtSignature = pendingDetail;
pendingDetails.delete(id);
}
},
consumeDetails(details: unknown) {
if (!Array.isArray(details)) {
return;
}
for (const detail of details) {
if (
!isRecord(detail) ||
detail.type !== "reasoning.encrypted" ||
typeof detail.id !== "string" ||
detail.id.length === 0 ||
typeof detail.data !== "string" ||
detail.data.length === 0
) {
continue;
}
const serializedDetail = JSON.stringify(detail);
const matchingBlock = firstBlocks.get(detail.id);
if (matchingBlock) {
matchingBlock.thoughtSignature = serializedDetail;
} else {
pendingDetails.set(detail.id, serializedDetail);
}
}
},
};
}
/** Normalize the SDK's legacy single-function lane into its modern tool-call shape. */
export function createOpenAICompletionsToolCallDeltaNormalizer(): (
delta: ChatCompletionChunk.Choice.Delta,
@@ -1675,6 +1675,78 @@ describe("openai-completions stop-reason tool-call guard", () => {
expect(toolCalls).toHaveLength(1);
});
it("preserves the first tool identity and publishes argument-free tool fragments", async () => {
mockChunksRef.chunks = [
makeToolCallChunk("call_original", "original", ""),
makeToolCallChunk("call_replaced", "replaced", '{"value":1}'),
makeFinishChunk("tool_calls"),
];
const stream = streamOpenAICompletions(model, context, { apiKey: "sk-test" });
const toolDeltas: string[] = [];
for await (const event of stream) {
if (event.type === "toolcall_delta") {
toolDeltas.push(event.delta);
}
}
expect(toolDeltas).toEqual(["", '{"value":1}']);
expect((await stream.result()).content).toContainEqual({
type: "toolCall",
id: "call_original",
name: "original",
arguments: { value: 1 },
});
});
it("publishes post-tool text immediately and closes blocks in their original order", async () => {
mockChunksRef.chunks = [
makeToolCallChunk("call_1", "lookup", '{"value":1}'),
makeTextChunk("following text"),
makeFinishChunk("tool_calls"),
];
const stream = streamOpenAICompletions(model, context, { apiKey: "sk-test" });
const eventTypes: string[] = [];
for await (const event of stream) {
eventTypes.push(event.type);
if (event.type === "text_delta") {
expect(event.partial).toBeDefined();
}
}
expect(eventTypes.indexOf("text_delta")).toBeLessThan(eventTypes.indexOf("toolcall_end"));
expect(eventTypes.indexOf("toolcall_end")).toBeLessThan(eventTypes.indexOf("text_end"));
expect((await stream.result()).content).toEqual([
{ type: "toolCall", id: "call_1", name: "lookup", arguments: { value: 1 } },
{
type: "text",
text: "following text",
textSignature: '{"v":1,"id":"commentary-0","phase":"commentary"}',
},
]);
});
it("rolls back provisional commentary when an unfinished tool stream is interrupted", async () => {
mockChunksRef.chunks = [
makeTextChunk("ordinary narration"),
makeToolCallChunk("call_1", "lookup", '{"value":1}'),
];
const stream = streamOpenAICompletions(model, context, { apiKey: "sk-test" });
const eventTypes: string[] = [];
for await (const event of stream) {
eventTypes.push(event.type);
}
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Stream ended without finish_reason");
expect(result.content).toEqual([{ type: "text", text: "ordinary narration" }]);
expect(eventTypes.indexOf("text_end")).toBeLessThan(eventTypes.indexOf("error"));
expect(eventTypes).not.toContain("toolcall_end");
});
it("strips toolCall blocks when finish_reason is stop after visible text", async () => {
mockChunksRef.chunks = [
makeTextChunk("Hello"),
+59 -502
View File
@@ -2,7 +2,6 @@
import OpenAI from "openai";
import type {
ChatCompletionAssistantMessageParam,
ChatCompletionChunk,
ChatCompletionContentPartText,
ChatCompletionDeveloperMessageParam,
ChatCompletionMessageParam,
@@ -17,15 +16,11 @@ import {
resolveOpenAICompletionsCompat,
type ResolvedOpenAICompletionsCompat,
} from "../transports/openai-completions-compat.js";
import { processCompletionsStream } from "../transports/openai-completions-stream.js";
import { resolveOpenAIReasoningEffortMap } from "../transports/openai-reasoning-compat.js";
import {
createOpenAIProviderAcceptanceHook,
isOpenAICompletionsThinkingEnabled,
parseOpenAICompletionsUsage,
readOpenAICompletionsContentDeltas,
readOpenAICompletionsReasoningBatch,
type OpenAICompletionsContentDelta,
type OpenAICompletionsTextSource,
} from "../transports/openai-transport-shared.js";
import {
transportAbortError,
@@ -38,49 +33,31 @@ import type {
Model,
SimpleStreamOptions,
StreamFunction,
TextContent,
ThinkingContent,
Tool,
ToolCall,
} from "../types.js";
import {
clearPendingCommentaryText,
rememberPendingCommentaryTags,
tagInterruptedTextPhases,
tagPendingCommentaryText,
tagUnresolvedTextAsCommentary,
type PendingCommentaryTags,
} from "../utils/assistant-text-phase.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import {
createToolArgumentPreviewSchedule,
parseStreamingJson,
type ToolArgumentPreviewSchedule,
} from "../utils/json-parse.js";
import { notifyLlmRequestActivity } from "../utils/llm-request-activity.js";
import { sortPromptCacheToolsByName } from "../utils/prompt-cache-stability.js";
import { projectProviderError } from "../utils/provider-error.js";
import { createReasoningTagTextPartitioner } from "../utils/reasoning-tag-text-partitioner.js";
import {
createFirstStreamEventAbortController,
getFirstStreamEventTimeoutHandler,
getFirstStreamEventTimeoutMs,
withFirstStreamEventTimeout,
} from "../utils/stream-first-event-timeout.js";
import { splitSystemPromptCacheBoundary } from "../utils/system-prompt-cache-boundary.js";
import { resolveCacheRetention } from "./cache-retention.js";
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
import {
createOpenAICompletionsToolCallDeltaNormalizer,
finalizeOpenAICompletionsToolCalls,
} from "./openai-completions-tool-calls.js";
import { finalizeOpenAICompletionsToolCalls } from "./openai-completions-tool-calls.js";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
import {
resolveOpenAICompletionsResponseFormat,
shouldOmitOllamaCompatResponseFormat,
} from "./openai-response-format.js";
import { mapOpenAIStopReason } from "./openai-stop-reason.js";
import {
projectOpenAITools,
reconcileOpenAICompletionsToolChoice,
@@ -96,26 +73,6 @@ interface OpenAICompatCacheControl {
ttl?: string;
}
type EncryptedReasoningDetail = {
type: "reasoning.encrypted";
id: string;
data: string;
};
function isEncryptedReasoningDetail(detail: unknown): detail is EncryptedReasoningDetail {
if (typeof detail !== "object" || detail === null) {
return false;
}
const candidate = detail as Record<string, unknown>;
return (
candidate.type === "reasoning.encrypted" &&
typeof candidate.id === "string" &&
candidate.id.length > 0 &&
typeof candidate.data === "string" &&
candidate.data.length > 0
);
}
type ChatCompletionInstructionMessageParam =
| ChatCompletionDeveloperMessageParam
| ChatCompletionSystemMessageParam;
@@ -153,12 +110,10 @@ export const streamOpenAICompletions: StreamFunction<
timestamp: Date.now(),
};
const provisionalCommentaryTags: PendingCommentaryTags = new Map();
let firstEventAbort: ReturnType<typeof createFirstStreamEventAbortController> | undefined;
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const compat = resolveOpenAICompletionsCompat(model);
const visibleReasoningDetailTypes = new Set(compat.visibleReasoningDetailTypes);
const shouldEmitReasoning = Boolean(
model.reasoning &&
options?.reasoningEffort &&
@@ -192,70 +147,22 @@ export const streamOpenAICompletions: StreamFunction<
onReady: () => stream.push({ type: "start", partial: output }),
});
interface StreamingToolCallBlock extends ToolCall {
partialArgs?: string;
streamIndex?: number;
}
type StreamingBlock = TextContent | ThinkingContent | StreamingToolCallBlock;
type StreamingToolCallDelta = NonNullable<
ChatCompletionChunk.Choice.Delta["tool_calls"]
>[number];
let textBlock: TextContent | null = null;
let textBlockSource: OpenAICompletionsTextSource | undefined;
let thinkingBlock: ThinkingContent | null = null;
let pendingInterruptedTextBlock: TextContent | null = null;
let confirmedInterruptedTextBlock: TextContent | null = null;
let hasFinishReason = false;
const toolCallBlocksByIndex = new Map<number, StreamingToolCallBlock>();
const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
const toolCallBlocksByFirstId = new Map<string, StreamingToolCallBlock>();
// Preview schedules are per active tool call; WeakMap keys die with the block.
const toolArgumentPreviewSchedules = new WeakMap<
StreamingToolCallBlock,
ToolArgumentPreviewSchedule
>();
const normalizeToolCallDeltas = createOpenAICompletionsToolCallDeltaNormalizer();
const pendingReasoningDetailsByToolCallId = new Map<string, string>();
const blocks = output.content as StreamingBlock[];
// A block can be finished mid-stream (native reasoning sealed at the
// text-lane transition) and again by the end-of-stream loop; guard so its
// *_end event is emitted exactly once.
type StreamingBlock = AssistantMessage["content"][number];
const finishedBlocks = new Set<StreamingBlock>();
const contentIndices = new WeakMap<StreamingBlock, number>();
let explicitVisibleTextBlocks: Set<TextContent> | undefined;
const appendBlock = (block: StreamingBlock) => {
contentIndices.set(block, blocks.length);
blocks.push(block);
};
const getContentIndex = (block: StreamingBlock) => contentIndices.get(block) ?? -1;
const rememberFirstToolCallById = (id: string, block: StreamingToolCallBlock) => {
if (toolCallBlocksByFirstId.has(id)) {
return;
}
toolCallBlocksByFirstId.set(id, block);
// Some gateways emit encrypted reasoning before the referenced call.
// Attach it once the first matching block exists so replay stays intact.
const pendingDetail = pendingReasoningDetailsByToolCallId.get(id);
if (pendingDetail) {
block.thoughtSignature = pendingDetail;
pendingReasoningDetailsByToolCallId.delete(id);
}
};
let openTextBlock: StreamingBlock | undefined;
let openThinkingBlock: StreamingBlock | undefined;
const finishBlock = (block: StreamingBlock) => {
const contentIndex = getContentIndex(block);
if (contentIndex === -1 || finishedBlocks.has(block)) {
const contentIndex = contentIndices.get(block);
if (contentIndex === undefined || finishedBlocks.has(block)) {
return;
}
finishedBlocks.add(block);
if (block.type === "text") {
stream.push({
type: "text_end",
contentIndex,
content: block.text,
partial: output,
});
openTextBlock = undefined;
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
} else if (block.type === "thinking") {
openThinkingBlock = undefined;
stream.push({
type: "thinking_end",
contentIndex,
@@ -263,419 +170,71 @@ export const streamOpenAICompletions: StreamFunction<
partial: output,
});
} else if (block.type === "toolCall") {
stream.push({
type: "toolcall_end",
contentIndex,
toolCall: block,
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: output });
}
};
const finishTextBlock = () => {
if (!textBlock) {
return;
}
finishBlock(textBlock);
textBlock = null;
textBlockSource = undefined;
};
const ensureTextBlock = (source: OpenAICompletionsTextSource | undefined) => {
if (textBlock && textBlockSource !== source) {
finishTextBlock();
}
if (!textBlock) {
textBlock = { type: "text", text: "" };
textBlockSource = source;
if (source === "reasoning_detail") {
(explicitVisibleTextBlocks ??= new Set()).add(textBlock);
const directEventStream = {
push(event: Parameters<typeof stream.push>[0]) {
if (
event.type === "text_start" ||
event.type === "thinking_start" ||
event.type === "toolcall_start"
) {
const block = output.content[event.contentIndex];
if (block) {
contentIndices.set(block, event.contentIndex);
if (block.type === "text") {
openTextBlock = block;
} else if (block.type === "thinking") {
openThinkingBlock = block;
}
}
}
appendBlock(textBlock);
stream.push({
type: "text_start",
contentIndex: getContentIndex(textBlock),
partial: output,
});
}
return textBlock;
stream.push(event);
},
};
const ensureThinkingBlock = (thinkingSignature: string | undefined) => {
if (!thinkingBlock) {
thinkingBlock = {
type: "thinking",
thinking: "",
...(thinkingSignature ? { thinkingSignature } : {}),
};
appendBlock(thinkingBlock);
stream.push({
type: "thinking_start",
contentIndex: getContentIndex(thinkingBlock),
partial: output,
});
}
return thinkingBlock;
};
// Native-thinking providers (e.g. deepseek `reasoning_content`) stream the
// reasoning lane, then switch to the answer via `content` with no boundary
// event. Seal the open thought when visible text begins so `thinking_end`
// precedes the answer; tag-based <think> reasoning has no native thinking
// block (it is closed by the partitioner), so this is a no-op there.
const sealNativeReasoningBeforeText = () => {
if (thinkingBlock && !reasoningTagTextPartitioner.isInsideReasoning()) {
finishBlock(thinkingBlock);
thinkingBlock = null;
}
};
const appendTextDelta = (delta: string, source?: OpenAICompletionsTextSource) => {
sealNativeReasoningBeforeText();
const block = ensureTextBlock(source);
block.text += delta;
if (pendingInterruptedTextBlock && delta.trim()) {
confirmedInterruptedTextBlock = pendingInterruptedTextBlock;
pendingInterruptedTextBlock = null;
}
stream.push({
type: "text_delta",
contentIndex: getContentIndex(block),
delta,
partial: output,
try {
await processCompletionsStream(hookedOpenAIStream, output, model, directEventStream, {
mode: "direct",
beforeContentBlock(nextType) {
if (openThinkingBlock) {
finishBlock(openThinkingBlock);
}
if (openTextBlock && nextType !== "toolCall") {
finishBlock(openTextBlock);
}
},
provisionalCommentaryTags,
signal: options?.signal,
emitReasoning: shouldEmitReasoning,
firstEventTimeoutMs: getFirstStreamEventTimeoutMs(options),
abortFirstEventStream: firstEventAbort.abort,
onFirstEventTimeout: getFirstStreamEventTimeoutHandler(options),
});
};
const appendThinkingDelta = (thinkingSignature: string | undefined, delta: string) => {
const block = ensureThinkingBlock(thinkingSignature);
block.thinking += delta;
stream.push({
type: "thinking_delta",
contentIndex: getContentIndex(block),
delta,
partial: output,
});
};
const appendReasoningDeltas = (reasoningDeltas: readonly OpenAICompletionsContentDelta[]) => {
for (const reasoningDelta of reasoningDeltas) {
if (reasoningDelta.kind === "thinking") {
if (!shouldEmitReasoning) {
continue;
}
finishTextBlock();
const signature = reasoningDelta.signature;
const thinkingSignature =
model.provider === "opencode-go" && signature === "reasoning"
? "reasoning_content"
: signature;
appendThinkingDelta(thinkingSignature, reasoningDelta.text);
} else {
appendTextDelta(reasoningDelta.text, reasoningDelta.source);
}
if (options?.signal?.aborted) {
throw transportAbortError(options.signal);
}
};
const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => {
const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined;
let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined;
if (!block && toolCall.id) {
block = toolCallBlocksById.get(toolCall.id);
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(
output.errorMessage ||
(output.stopReason === "aborted"
? "Request was aborted"
: "Provider returned an invalid tool call"),
);
}
if (!block) {
block = {
type: "toolCall",
id: toolCall.id || "",
name: toolCall.function?.name || "",
arguments: {},
partialArgs: "",
streamIndex,
};
if (streamIndex !== undefined) {
toolCallBlocksByIndex.set(streamIndex, block);
}
if (toolCall.id) {
toolCallBlocksById.set(toolCall.id, block);
rememberFirstToolCallById(toolCall.id, block);
}
toolArgumentPreviewSchedules.set(block, createToolArgumentPreviewSchedule());
appendBlock(block);
stream.push({
type: "toolcall_start",
contentIndex: getContentIndex(block),
partial: output,
});
}
if (streamIndex !== undefined && block.streamIndex === undefined) {
block.streamIndex = streamIndex;
toolCallBlocksByIndex.set(streamIndex, block);
}
if (toolCall.id) {
toolCallBlocksById.set(toolCall.id, block);
}
return block;
};
const reasoningTagTextPartitioner = createReasoningTagTextPartitioner();
const appendPartitionedContent = (text: string, hasMirroredReasoning: boolean) => {
const routedDeltas = hasMirroredReasoning
? reasoningTagTextPartitioner.push(text)
: reasoningTagTextPartitioner.pushVisible(text);
for (const delta of routedDeltas) {
if (delta.kind === "text") {
appendTextDelta(delta.text);
}
}
};
const flushPartitionedContent = () => {
for (const delta of reasoningTagTextPartitioner.flush()) {
if (delta.kind === "text") {
appendTextDelta(delta.text);
}
}
};
const sealTextBeforeReasoning = () => {
if (!textBlock && !reasoningTagTextPartitioner.hasPending()) {
return;
}
flushPartitionedContent();
if (!textBlock) {
return;
}
// Resumed reasoning makes the preceding visible text interim. Preserve
// the candidate boundary only if later text confirms a final answer.
if (textBlockSource !== "reasoning_detail" && textBlock.text.trim()) {
pendingInterruptedTextBlock = textBlock;
}
finishTextBlock();
};
const beginReasoning = (hasFollowingVisibleText: boolean, forceStrict = false) => {
if (!output.openclawDelivery?.textPhaseRequiresTerminal) {
output.openclawDelivery = {
...output.openclawDelivery,
textPhaseRequiresTerminal: true,
};
}
if (forceStrict || reasoningTagTextPartitioner.hasPending()) {
reasoningTagTextPartitioner.markStrict();
}
// Let following text finish syntax already owned by the Markdown
// parser; otherwise packet batching cannot erase a lane boundary.
if (!hasFollowingVisibleText || !reasoningTagTextPartitioner.hasPendingSyntax()) {
sealTextBeforeReasoning();
}
};
const guardedOpenaiStream = withFirstStreamEventTimeout(hookedOpenAIStream, {
provider: model.provider,
api: model.api,
model: model.id,
timeoutMs: getFirstStreamEventTimeoutMs(options) ?? 0,
stage: "completions",
abort: firstEventAbort.abort,
onTimeout: getFirstStreamEventTimeoutHandler(options),
hint: "The provider may be stalled while parsing the tool payload; retry with a smaller tool surface or enable OPENCLAW_DEBUG_MODEL_PAYLOAD=tools to inspect exposed tools.",
});
for await (const chunk of guardedOpenaiStream) {
if (!chunk || typeof chunk !== "object") {
continue;
}
// Hidden reasoning is still provider progress; keep the idle watchdog alive without exposing it.
notifyLlmRequestActivity(options?.signal);
// OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
// and each chunk in a streamed completion carries the same id.
output.responseId ||= chunk.id;
if (typeof chunk.model === "string" && chunk.model.length > 0 && chunk.model !== model.id) {
output.responseModel ||= chunk.model;
}
if (chunk.usage) {
output.usage = parseOpenAICompletionsUsage(chunk.usage, model, {
includeReasoningTokens: false,
});
}
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice) {
continue;
}
// Fallback: some providers (e.g., Moonshot) return usage
// in choice.usage instead of the standard chunk.usage
const choiceUsage = (
choice as typeof choice & { usage?: Parameters<typeof parseOpenAICompletionsUsage>[0] }
).usage;
if (!chunk.usage && choiceUsage) {
output.usage = parseOpenAICompletionsUsage(choiceUsage, model, {
includeReasoningTokens: false,
});
}
if (choice.finish_reason) {
const reason = mapOpenAIStopReason(choice.finish_reason, { allowSingularToolCall: true });
output.stopReason = reason.stopReason;
if (reason.errorMessage) {
output.errorMessage = reason.errorMessage;
}
hasFinishReason = true;
}
// Some OpenAI-compatible endpoints deliver a full `message` instead of
// `delta` (including refusal-only turns with content: null). Normalize
// the same way the managed agent transport does.
const rawChoiceDelta =
choice.delta ??
(choice as { message?: ChatCompletionChunk["choices"][number]["delta"] }).message;
if (rawChoiceDelta) {
for (const normalizedDelta of normalizeToolCallDeltas(
rawChoiceDelta,
choice.finish_reason,
)) {
const choiceDelta = normalizedDelta.delta;
const deltaFields = choiceDelta as Record<string, unknown>;
const reasoningBatch = readOpenAICompletionsReasoningBatch(
deltaFields,
visibleReasoningDetailTypes,
);
const reasoningDeltas = reasoningBatch.deltas;
const hasReasoningThinking = reasoningBatch.hasThinking;
const contentDeltas = readOpenAICompletionsContentDeltas(
choiceDelta.content,
choiceDelta.refusal,
reasoningBatch.mirroredThinking,
);
const lastVisibleTextIndex = contentDeltas.findLastIndex(
(delta) => delta.kind === "text",
);
const hasSameChunkVisibleText =
reasoningBatch.hasVisibleText || lastVisibleTextIndex !== -1;
if (hasReasoningThinking) {
beginReasoning(hasSameChunkVisibleText, true);
appendReasoningDeltas(reasoningDeltas);
}
for (const [contentDeltaIndex, contentDelta] of contentDeltas.entries()) {
if (contentDelta.kind === "thinking") {
const hasLaterVisibleText = contentDeltaIndex < lastVisibleTextIndex;
beginReasoning(hasLaterVisibleText);
if (shouldEmitReasoning) {
appendThinkingDelta(contentDelta.signature, contentDelta.text);
}
} else {
appendPartitionedContent(contentDelta.text, hasReasoningThinking);
}
}
if (!hasReasoningThinking) {
appendReasoningDeltas(reasoningDeltas);
}
const toolCallDeltas = normalizedDelta.toolCalls;
if (toolCallDeltas.length > 0) {
flushPartitionedContent();
// The tool-call lane is also a reasoning boundary; seal the thought
// before toolcall_start so thinking_end never trails the action.
sealNativeReasoningBeforeText();
rememberPendingCommentaryTags(
provisionalCommentaryTags,
tagPendingCommentaryText(output.content),
);
for (const toolCall of toolCallDeltas) {
const block = ensureToolCallBlock(toolCall);
if (!block.id && toolCall.id) {
block.id = toolCall.id;
toolCallBlocksById.set(toolCall.id, block);
rememberFirstToolCallById(toolCall.id, block);
}
if (!block.name && toolCall.function?.name) {
block.name = toolCall.function.name;
}
let delta = "";
if (toolCall.function?.arguments) {
delta = toolCall.function.arguments;
block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments;
// Preview refresh is scheduled geometrically; the terminal
// finalize re-parses the full buffer authoritatively either way.
if (toolArgumentPreviewSchedules.get(block)?.(block.partialArgs.length)) {
block.arguments = parseStreamingJson(block.partialArgs);
}
}
stream.push({
type: "toolcall_delta",
contentIndex: getContentIndex(block),
delta,
partial: output,
});
}
}
const reasoningDetails = (choiceDelta as { reasoning_details?: unknown })
.reasoning_details;
if (Array.isArray(reasoningDetails)) {
for (const detail of reasoningDetails) {
if (isEncryptedReasoningDetail(detail)) {
const serializedDetail = JSON.stringify(detail);
const matchingToolCall = toolCallBlocksByFirstId.get(detail.id);
if (matchingToolCall) {
matchingToolCall.thoughtSignature = serializedDetail;
} else {
pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail);
}
}
}
}
}
}
}
flushPartitionedContent();
let terminalError: Error | undefined;
if (options?.signal?.aborted) {
terminalError = transportAbortError(options.signal);
} else if (output.stopReason === "aborted") {
terminalError = new Error("Request was aborted");
} else if (output.stopReason === "error") {
terminalError = new Error(output.errorMessage || "Provider returned an error stop reason");
} else if (!hasFinishReason) {
terminalError = new Error("Stream ended without finish_reason");
}
if (terminalError) {
for (const block of blocks) {
} catch (error) {
for (const block of output.content) {
if (block.type !== "toolCall") {
finishBlock(block);
}
}
throw terminalError;
throw error;
}
finalizeOpenAICompletionsToolCalls(output);
if (output.stopReason === "aborted" || output.stopReason === "error") {
for (const block of blocks) {
if (block.type !== "toolCall") {
finishBlock(block);
}
}
throw new Error(
output.errorMessage ||
(output.stopReason === "aborted"
? "Request was aborted"
: "Provider returned an invalid tool call"),
);
}
if (output.stopReason !== "toolUse" && confirmedInterruptedTextBlock) {
tagInterruptedTextPhases(
output.content,
confirmedInterruptedTextBlock,
explicitVisibleTextBlocks,
);
}
// Tool completion is irreversible: confirm the terminal before closing
// blocks, then preserve their original text/thinking/tool event order.
for (const block of blocks) {
for (const block of output.content) {
if (block.type !== "toolCall" || output.stopReason === "toolUse") {
finishBlock(block);
}
}
if (output.stopReason !== "toolUse") {
clearPendingCommentaryText(provisionalCommentaryTags);
}
if (output.stopReason === "toolUse") {
tagPendingCommentaryText(output.content);
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
@@ -1165,5 +724,3 @@ function convertTools(
})),
};
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -4,6 +4,7 @@ import type { ChatCompletionChunk } from "openai/resources/chat/completions.js";
import type { OpenAICompletionsOptions } from "../provider-options.js";
import {
createOpenAICompletionsToolCallDeltaNormalizer,
createOpenAIEncryptedToolCallReasoningTracker,
finalizeOpenAICompletionsToolCalls,
} from "../providers/openai-completions-tool-calls.js";
import { mapOpenAIStopReason } from "../providers/openai-stop-reason.js";
@@ -53,6 +54,22 @@ type OpenAICompatibleChatCompletionChunk = Omit<ChatCompletionChunk, "choices">
choices: OpenAICompatibleChoice[];
};
type CompletionsStreamOptions = {
signal?: AbortSignal;
emitReasoning?: boolean;
firstEventTimeoutMs?: number;
abortFirstEventStream?: (reason: Error) => void;
onFirstEventTimeout?: (reason: Error) => void;
sawStreamDONE?: () => boolean;
} & (
| {
mode: "direct";
beforeContentBlock: (nextType: "text" | "thinking" | "toolCall") => void;
provisionalCommentaryTags: PendingCommentaryTags;
}
| { mode?: "managed"; beforeContentBlock?: never }
);
function extractToolCallThoughtSignature(toolCall: unknown): string | undefined {
const tc = toolCall as Record<string, unknown> | undefined;
if (!tc) {
@@ -79,20 +96,14 @@ export async function processCompletionsStream(
output: MutableAssistantOutput,
model: Model,
stream: { push(event: AssistantMessageEvent): void },
options?: {
signal?: AbortSignal;
emitReasoning?: boolean;
firstEventTimeoutMs?: number;
abortFirstEventStream?: (reason: Error) => void;
onFirstEventTimeout?: (reason: Error) => void;
sawStreamDONE?: () => boolean;
},
options?: CompletionsStreamOptions,
) {
const MAX_POST_TOOL_CALL_BUFFER_BYTES = 256_000;
const directMode = options?.mode === "direct";
const emitReasoning = options?.emitReasoning ?? true;
const compat = getCompat(model as OpenAIModeModel);
const visibleReasoningDetailTypes = new Set(compat.visibleReasoningDetailTypes);
const shouldFilterDeepSeekDsmlText = compat.thinkingFormat === "deepseek";
const shouldFilterDeepSeekDsmlText = !directMode && compat.thinkingFormat === "deepseek";
const deepSeekTextFilter = shouldFilterDeepSeekDsmlText ? createDeepSeekTextFilter() : null;
const deepSeekToolCallRecoverer = shouldFilterDeepSeekDsmlText ? createDsmlRecoverer() : null;
const reasoningTagTextPartitioner = createReasoningTagTextPartitioner();
@@ -105,11 +116,10 @@ export async function processCompletionsStream(
thoughtSignature?: string;
};
type TextBlock = { type: "text"; text: string; textSignature?: string };
let currentBlock:
| TextBlock
| { type: "thinking"; thinking: string; thinkingSignature?: string }
| ToolCallBlock
| null = null;
type ThinkingBlock = { type: "thinking"; thinking: string; thinkingSignature?: string };
let currentBlock: TextBlock | ThinkingBlock | ToolCallBlock | null = null;
let directTextBlock: TextBlock | null = null;
let directThinkingBlock: ThinkingBlock | null = null;
let currentTextSource: OpenAICompletionsTextSource | undefined;
let pendingInterruptedTextBlock: TextBlock | null = null;
let confirmedInterruptedTextBlock: TextBlock | null = null;
@@ -118,15 +128,22 @@ export async function processCompletionsStream(
let isFlushingPendingPostToolCallDeltas = false;
const toolCallBlocksByIndex = new Map<number, ToolCallBlock>();
const toolCallBlocksById = new Map<string, ToolCallBlock>();
const encryptedReasoning = directMode
? createOpenAIEncryptedToolCallReasoningTracker()
: undefined;
// Preview schedules are per active tool call; WeakMap keys die with the block.
const toolArgumentPreviewSchedules = new WeakMap<ToolCallBlock, ToolArgumentPreviewSchedule>();
const provisionalCommentaryTags: PendingCommentaryTags = new Map();
const provisionalCommentaryTags = directMode ? options.provisionalCommentaryTags : new Map();
const contentBlockIndices = new WeakMap<TextBlock | ThinkingBlock, number>();
const toolCallBlockIndices = new WeakMap<ToolCallBlock, number>();
let explicitVisibleTextBlocks: Set<TextBlock> | undefined;
const normalizeToolCallDeltas = createOpenAICompletionsToolCallDeltaNormalizer();
let finishReason: string | undefined;
let sawNativeToolCallDelta = false;
const blockIndex = () => output.content.length - 1;
const blockIndex = () =>
directMode && currentBlock && currentBlock.type !== "toolCall"
? (contentBlockIndices.get(currentBlock) ?? output.content.length - 1)
: output.content.length - 1;
const measureUtf8Bytes = (text: string) => Buffer.byteLength(text, "utf8");
let chunkPushedEvent = false;
const pushStreamEvent = (event: AssistantMessageEvent) => {
@@ -159,13 +176,23 @@ export async function processCompletionsStream(
previous.text += next.text;
};
const appendThinkingDeltaInternal = (reasoningDelta: { signature?: string; text: string }) => {
if (directMode && directThinkingBlock) {
currentBlock = directThinkingBlock;
}
if (!currentBlock || currentBlock.type !== "thinking") {
options?.beforeContentBlock?.("thinking");
const thinkingSignature = reasoningDelta.signature;
currentBlock = {
type: "thinking",
thinking: "",
...(reasoningDelta.signature ? { thinkingSignature: reasoningDelta.signature } : {}),
...(thinkingSignature ? { thinkingSignature } : {}),
};
if (directMode) {
directTextBlock = null;
directThinkingBlock = currentBlock;
}
output.content.push(currentBlock);
contentBlockIndices.set(currentBlock, output.content.length - 1);
pushStreamEvent({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
}
currentBlock.thinking += reasoningDelta.text;
@@ -177,16 +204,25 @@ export async function processCompletionsStream(
});
};
const appendTextDeltaInternal = (text: string, source?: OpenAICompletionsTextSource) => {
if (directMode && directTextBlock) {
currentBlock = directTextBlock;
}
if (currentBlock?.type === "text" && currentTextSource !== source) {
currentBlock = null;
}
if (!currentBlock || currentBlock.type !== "text") {
options?.beforeContentBlock?.("text");
currentBlock = { type: "text", text: "" };
currentTextSource = source;
if (directMode) {
directTextBlock = currentBlock;
directThinkingBlock = null;
}
if (source === "reasoning_detail") {
(explicitVisibleTextBlocks ??= new Set()).add(currentBlock);
}
output.content.push(currentBlock);
contentBlockIndices.set(currentBlock, output.content.length - 1);
pushStreamEvent({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
currentBlock.text += text;
@@ -198,6 +234,7 @@ export async function processCompletionsStream(
type: "text_delta",
contentIndex: blockIndex(),
delta: text,
...(directMode ? { partial: output } : {}),
});
};
const flushPendingPostToolCallDeltas = () => {
@@ -233,7 +270,7 @@ export async function processCompletionsStream(
if (!text) {
return;
}
if (currentBlock?.type === "toolCall") {
if (currentBlock?.type === "toolCall" && !directMode) {
queuePostToolCallDelta({ kind: "text", text });
} else {
appendTextDelta(text);
@@ -244,14 +281,18 @@ export async function processCompletionsStream(
if (reasoningDelta.kind === "thinking" && !emitReasoning) {
continue;
}
if (currentBlock?.type === "toolCall") {
if (currentBlock?.type === "toolCall" && !directMode) {
queuePostToolCallDelta({ ...reasoningDelta });
continue;
}
if (reasoningDelta.kind === "text") {
appendTextDelta(reasoningDelta.text, reasoningDelta.source);
} else if (emitReasoning) {
appendThinkingDelta(reasoningDelta);
appendThinkingDelta(
directMode && model.provider === "opencode-go" && reasoningDelta.signature === "reasoning"
? { ...reasoningDelta, signature: "reasoning_content" }
: reasoningDelta,
);
}
}
};
@@ -338,7 +379,7 @@ export async function processCompletionsStream(
if (!emitReasoning) {
return;
}
if (currentBlock?.type === "toolCall") {
if (currentBlock?.type === "toolCall" && !directMode) {
queuePostToolCallDelta(delta);
} else {
appendThinkingDelta(delta);
@@ -350,7 +391,7 @@ export async function processCompletionsStream(
}
};
const emitReasoningUsageActivity = (hasReasoningUsageActivity: boolean) => {
if (!hasReasoningUsageActivity || chunkPushedEvent || !emitReasoning) {
if (directMode || !hasReasoningUsageActivity || chunkPushedEvent || !emitReasoning) {
return;
}
const latestBlock = output.content[output.content.length - 1];
@@ -381,6 +422,9 @@ export async function processCompletionsStream(
pendingInterruptedTextBlock = currentBlock;
}
currentBlock = null;
if (directMode) {
directTextBlock = null;
}
currentTextSource = undefined;
};
const beginReasoning = (hasFollowingVisibleText: boolean, forceStrict = false) => {
@@ -399,7 +443,9 @@ export async function processCompletionsStream(
sealTextBeforeReasoning();
}
};
const cooperativeScheduler = createModelStreamCooperativeScheduler(options?.signal);
const cooperativeScheduler = directMode
? undefined
: createModelStreamCooperativeScheduler(options?.signal);
const guardedStream = withFirstStreamEventTimeout(responseStream as AsyncIterable<unknown>, {
provider: model.provider,
api: model.api,
@@ -414,7 +460,9 @@ export async function processCompletionsStream(
throwIfModelStreamAborted(options?.signal);
chunkPushedEvent = false;
if (!rawChunk || typeof rawChunk !== "object") {
await cooperativeScheduler.afterEvent();
if (cooperativeScheduler) {
await cooperativeScheduler.afterEvent();
}
continue;
}
// Hidden reasoning is still provider progress; keep the idle watchdog alive without exposing it.
@@ -429,18 +477,24 @@ export async function processCompletionsStream(
}
let hasReasoningUsageActivity = false;
if (chunk.usage) {
output.usage = parseOpenAICompletionsUsage(chunk.usage, model);
output.usage = parseOpenAICompletionsUsage(chunk.usage, model, {
includeReasoningTokens: !directMode,
});
hasReasoningUsageActivity = hasOpenAICompletionsReasoningUsageActivity(chunk.usage);
}
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice) {
emitReasoningUsageActivity(hasReasoningUsageActivity);
await cooperativeScheduler.afterEvent();
if (cooperativeScheduler) {
await cooperativeScheduler.afterEvent();
}
continue;
}
const choiceUsage = choice.usage;
if (!chunk.usage && choiceUsage) {
output.usage = parseOpenAICompletionsUsage(choiceUsage, model);
output.usage = parseOpenAICompletionsUsage(choiceUsage, model, {
includeReasoningTokens: !directMode,
});
hasReasoningUsageActivity = hasOpenAICompletionsReasoningUsageActivity(choiceUsage);
}
if (choice.finish_reason) {
@@ -456,13 +510,16 @@ export async function processCompletionsStream(
const rawChoiceDelta = choice.delta ?? choice.message;
if (!rawChoiceDelta) {
emitReasoningUsageActivity(hasReasoningUsageActivity);
await cooperativeScheduler.afterEvent();
if (cooperativeScheduler) {
await cooperativeScheduler.afterEvent();
}
continue;
}
for (const normalizedDelta of normalizeToolCallDeltas(rawChoiceDelta, choice.finish_reason)) {
const choiceDelta = normalizedDelta.delta;
const deltaFields = choiceDelta as Record<string, unknown>;
const reasoningBatch = readOpenAICompletionsReasoningBatch(
choiceDelta as Record<string, unknown>,
deltaFields,
visibleReasoningDetailTypes,
);
const reasoningDeltas = reasoningBatch.deltas;
@@ -517,7 +574,11 @@ export async function processCompletionsStream(
currentBlock = null;
flushPendingPostToolCallDeltas();
}
const initialSig = extractToolCallThoughtSignature(toolCall);
const initialSig = directMode ? undefined : extractToolCallThoughtSignature(toolCall);
options?.beforeContentBlock?.("toolCall");
if (directMode) {
directThinkingBlock = null;
}
block = {
type: "toolCall",
id: toolCall.id || "",
@@ -526,6 +587,7 @@ export async function processCompletionsStream(
partialArgs: "",
...(initialSig ? { thoughtSignature: initialSig } : {}),
};
encryptedReasoning?.rememberToolCall(block.id, block);
toolArgumentPreviewSchedules.set(block, createToolArgumentPreviewSchedule());
output.content.push(block);
toolCallBlockIndices.set(block, output.content.length - 1);
@@ -539,39 +601,50 @@ export async function processCompletionsStream(
toolCallBlocksByIndex.set(streamIndex, block);
}
if (toolCall.id) {
block.id = toolCall.id;
if (!directMode || !block.id) {
block.id = toolCall.id;
}
toolCallBlocksById.set(toolCall.id, block);
if (block.id === toolCall.id) {
encryptedReasoning?.rememberToolCall(toolCall.id, block);
}
}
currentBlock = block;
if (toolCall.function?.name) {
if (toolCall.function?.name && (!directMode || !block.name)) {
block.name = toolCall.function.name;
}
const deltaSig = extractToolCallThoughtSignature(toolCall);
const deltaSig = directMode ? undefined : extractToolCallThoughtSignature(toolCall);
if (deltaSig) {
block.thoughtSignature = deltaSig;
}
if (toolCall.function?.arguments) {
block.partialArgs += toolCall.function.arguments;
const toolArgumentsDelta = toolCall.function?.arguments;
if (toolArgumentsDelta) {
block.partialArgs += toolArgumentsDelta;
// Preview refresh is scheduled geometrically; the terminal
// finalize re-parses the full buffer authoritatively either way.
if (toolArgumentPreviewSchedules.get(block)?.(block.partialArgs.length)) {
block.arguments = parseStreamingJson(block.partialArgs);
}
}
if (toolArgumentsDelta || directMode) {
pushStreamEvent({
type: "toolcall_delta",
contentIndex: toolCallBlockIndices.get(block) ?? -1,
delta: toolCall.function.arguments,
delta: toolArgumentsDelta ?? "",
partial: output,
});
}
}
}
encryptedReasoning?.consumeDetails(deltaFields.reasoning_details);
}
flushPendingPostToolCallDeltas();
emitReasoningUsageActivity(hasReasoningUsageActivity);
await cooperativeScheduler.afterEvent();
if (cooperativeScheduler) {
await cooperativeScheduler.afterEvent();
}
}
if (!finishReason && options?.sawStreamDONE?.() === false) {
if (!finishReason && (directMode || options?.sawStreamDONE?.() === false)) {
throw new Error("Stream ended without finish_reason");
}
flushReasoningTagTextPartitioner();
@@ -584,7 +657,7 @@ export async function processCompletionsStream(
allowSilentToolCallPromotion:
finishReason === "stop" || (sawNativeToolCallDelta && (options?.sawStreamDONE?.() ?? false)),
onConfirmedToolCall(block, contentIndex) {
if (block.type !== "toolCall") {
if (directMode || block.type !== "toolCall") {
return;
}
pushStreamEvent({