mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
d57a5d9900
commit
f8e5674cc3
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user