Files
openclaw/extensions/xai/stream.ts
2026-05-16 22:25:43 +01:00

334 lines
11 KiB
TypeScript

import type { StreamFn } from "@earendil-works/pi-agent-core";
import { streamSimple } from "@earendil-works/pi-ai";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import {
composeProviderStreamWrappers,
createToolStreamWrapper,
} from "openclaw/plugin-sdk/provider-stream-shared";
const XAI_FAST_MODEL_IDS = new Map<string, string>([
["grok-3", "grok-3-fast"],
["grok-3-mini", "grok-3-mini-fast"],
["grok-4", "grok-4-fast"],
["grok-4-0709", "grok-4-fast"],
]);
function resolveXaiFastModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
return XAI_FAST_MODEL_IDS.get(modelId.trim());
}
function stripUnsupportedStrictFlag(tool: unknown): unknown {
if (!tool || typeof tool !== "object") {
return tool;
}
const toolObj = tool as Record<string, unknown>;
const fn = toolObj.function;
if (!fn || typeof fn !== "object") {
return tool;
}
const fnObj = fn as Record<string, unknown>;
if (typeof fnObj.strict !== "boolean") {
return tool;
}
const nextFunction = { ...fnObj };
delete nextFunction.strict;
return { ...toolObj, function: nextFunction };
}
function supportsExplicitImageInput(model: { input?: unknown }): boolean {
return Array.isArray(model.input) && model.input.includes("image");
}
function supportsReasoningControls(model: { compat?: unknown; reasoning?: unknown }): boolean {
const compat =
model.compat && typeof model.compat === "object"
? (model.compat as { supportsReasoningEffort?: unknown })
: undefined;
return model.reasoning === true && compat?.supportsReasoningEffort !== false;
}
const TOOL_RESULT_IMAGE_REPLAY_TEXT = "Attached image(s) from tool result:";
const HTML_ENTITY_RE = /&(?:amp|lt|gt|quot|apos|#39|#x[0-9a-f]+|#\d+);/i;
function decodeHtmlEntities(value: string): string {
return value
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&apos;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#(\d+);/gi, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10)));
}
function decodeHtmlEntitiesInObject(value: unknown): unknown {
if (typeof value === "string") {
return HTML_ENTITY_RE.test(value) ? decodeHtmlEntities(value) : value;
}
if (Array.isArray(value)) {
return value.map(decodeHtmlEntitiesInObject);
}
if (value && typeof value === "object") {
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
result[key] = decodeHtmlEntitiesInObject(entry);
}
return result;
}
return value;
}
function visitContentBlocks(
value: unknown,
visitor: (block: Record<string, unknown>) => void,
): void {
if (Array.isArray(value)) {
for (const entry of value) {
visitContentBlocks(entry, visitor);
}
return;
}
if (!value || typeof value !== "object") {
return;
}
const block = value as Record<string, unknown>;
visitor(block);
if ("content" in block) {
visitContentBlocks(block.content, visitor);
}
}
function decodeToolCallArgumentsHtmlEntitiesInMessage(message: unknown): void {
visitContentBlocks(message, (block) => {
if (block.type !== "toolCall" || !block.arguments || typeof block.arguments !== "object") {
return;
}
block.arguments = decodeHtmlEntitiesInObject(block.arguments);
});
}
type ReplayableInputImagePart =
| {
type: "input_image";
source: { type: "url"; url: string } | { type: "base64"; media_type: string; data: string };
}
| { type: "input_image"; image_url: string; detail?: string };
type NormalizedFunctionCallOutput = {
normalizedItem: unknown;
imageParts: Array<Record<string, unknown>>;
};
function isReplayableInputImagePart(
part: Record<string, unknown>,
): part is ReplayableInputImagePart {
if (part.type !== "input_image") {
return false;
}
if (typeof part.image_url === "string") {
return true;
}
if (!part.source || typeof part.source !== "object") {
return false;
}
const source = part.source as {
type?: unknown;
url?: unknown;
media_type?: unknown;
data?: unknown;
};
if (source.type === "url") {
return typeof source.url === "string";
}
return (
source.type === "base64" &&
typeof source.media_type === "string" &&
typeof source.data === "string"
);
}
function normalizeXaiResponsesFunctionCallOutput(
item: unknown,
includeImages: boolean,
): NormalizedFunctionCallOutput {
if (!item || typeof item !== "object") {
return { normalizedItem: item, imageParts: [] };
}
const itemObj = item as Record<string, unknown>;
if (itemObj.type !== "function_call_output" || !Array.isArray(itemObj.output)) {
return { normalizedItem: itemObj, imageParts: [] };
}
const outputParts = itemObj.output as Array<Record<string, unknown>>;
const textOutput = outputParts
.filter(
(part): part is { type: "input_text"; text: string } =>
part.type === "input_text" && typeof part.text === "string",
)
.map((part) => part.text)
.join("");
const imageParts = includeImages
? outputParts.filter((part): part is ReplayableInputImagePart =>
isReplayableInputImagePart(part),
)
: [];
const hadNonTextParts = outputParts.some((part) => part.type !== "input_text");
return {
normalizedItem: {
...itemObj,
output: textOutput || (hadNonTextParts ? "(see attached image)" : ""),
},
imageParts,
};
}
function normalizeXaiResponsesToolResultPayload(
payloadObj: Record<string, unknown>,
model: { api?: unknown; input?: unknown },
): void {
if (model.api !== "openai-responses" || !Array.isArray(payloadObj.input)) {
return;
}
const includeImages = supportsExplicitImageInput(model);
const normalizedInput: unknown[] = [];
const collectedImageParts: Array<Record<string, unknown>> = [];
for (const item of payloadObj.input) {
const normalized = normalizeXaiResponsesFunctionCallOutput(item, includeImages);
normalizedInput.push(normalized.normalizedItem);
collectedImageParts.push(...normalized.imageParts);
}
if (collectedImageParts.length > 0) {
normalizedInput.push({
type: "message",
role: "user",
content: [
{ type: "input_text", text: TOOL_RESULT_IMAGE_REPLAY_TEXT },
...collectedImageParts,
],
});
}
payloadObj.input = normalizedInput;
}
export function createXaiToolPayloadCompatibilityWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
if (Array.isArray(payloadObj.tools)) {
payloadObj.tools = payloadObj.tools.map((tool) => stripUnsupportedStrictFlag(tool));
}
normalizeXaiResponsesToolResultPayload(payloadObj, model);
if (!supportsReasoningControls(model)) {
delete payloadObj.reasoning;
delete payloadObj.reasoningEffort;
delete payloadObj.reasoning_effort;
}
}
return originalOnPayload?.(payload, model);
},
});
};
}
export function createXaiFastModeWrapper(
baseStreamFn: StreamFn | undefined,
fastMode: boolean,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const supportsFastAliasTransport =
model.api === "openai-completions" || model.api === "openai-responses";
if (!fastMode || !supportsFastAliasTransport || model.provider !== "xai") {
return underlying(model, context, options);
}
const fastModelId = resolveXaiFastModelId(model.id);
if (!fastModelId) {
return underlying(model, context, options);
}
return underlying({ ...model, id: fastModelId }, context, options);
};
}
function wrapStreamMessageObjects(
stream: ReturnType<typeof streamSimple>,
transformMessage: (message: unknown) => void,
): ReturnType<typeof streamSimple> {
const originalResult = stream.result.bind(stream);
stream.result = async () => {
const message = await originalResult();
transformMessage(message);
return message;
};
const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream);
(stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] =
function () {
const iterator = originalAsyncIterator();
return {
async next() {
const result = await iterator.next();
if (!result.done && result.value && typeof result.value === "object") {
const event = result.value as { partial?: unknown; message?: unknown };
transformMessage(event.partial);
transformMessage(event.message);
}
return result;
},
async return(value?: unknown) {
return iterator.return?.(value) ?? { done: true as const, value: undefined };
},
async throw(error?: unknown) {
return iterator.throw?.(error) ?? { done: true as const, value: undefined };
},
};
};
return stream;
}
function createXaiToolCallArgumentDecodingWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const maybeStream = underlying(model, context, options);
if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
return Promise.resolve(maybeStream).then((stream) =>
wrapStreamMessageObjects(stream, decodeToolCallArgumentsHtmlEntitiesInMessage),
);
}
return wrapStreamMessageObjects(maybeStream, decodeToolCallArgumentsHtmlEntitiesInMessage);
};
}
export function wrapXaiProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn | undefined {
const extraParams = ctx.extraParams;
const fastMode = extraParams?.fastMode;
const toolStreamEnabled = extraParams?.tool_stream !== false;
return composeProviderStreamWrappers(ctx.streamFn, (streamFn) => {
let wrappedStreamFn = createXaiToolPayloadCompatibilityWrapper(streamFn);
if (typeof fastMode === "boolean") {
wrappedStreamFn = createXaiFastModeWrapper(wrappedStreamFn, fastMode);
}
wrappedStreamFn = createXaiToolCallArgumentDecodingWrapper(wrappedStreamFn);
return createToolStreamWrapper(wrappedStreamFn, toolStreamEnabled);
});
}