Files
2026-07-13 13:39:12 +08:00

826 lines
27 KiB
TypeScript

import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
/**
* Extract a provider error message from a buffered SSE stream that carries an
* error-only chunk (`data: {"error":...}`) and no content chunks.
*
* Some executors always return `text/event-stream` even on failure (e.g. the
* Devin/Windsurf CLI executors emit `data: {"error":{"message":"Devin CLI not
* found..."}}`). Those chunks have no `choices`/Claude/Responses content, so the
* content parsers (parseSSEToOpenAIResponse etc.) correctly return `null`. Without
* this helper the caller would replace the real upstream error with a generic
* "Invalid SSE response" 502, swallowing the actionable message (#3324).
*
* Provider-agnostic: matches any `data:` chunk that has an `error` field but no
* `choices` array. The returned message is always run through sanitizeErrorMessage
* so stack traces / absolute source paths never leak (Hard Rule #12). Returns
* `null` when no error-only chunk is present (so valid-content streams are left
* to the normal parsers).
*/
export function extractSSEErrorMessage(rawSSE: unknown): string | null {
const lines = String(rawSSE || "").split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue; // Ignore malformed lines and keep scanning.
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
const record = parsed as Record<string, unknown>;
// A chunk with content (choices) is not an error-only chunk — defer to the
// normal content parsers so the valid-SSE path is never short-circuited.
if (Array.isArray(record.choices)) continue;
const err = record.error;
if (err == null) continue;
let message = "";
if (typeof err === "string") {
message = err;
} else if (typeof err === "object" && !Array.isArray(err)) {
const errRecord = err as Record<string, unknown>;
if (typeof errRecord.message === "string") {
message = errRecord.message;
} else {
message = JSON.stringify(err);
}
} else {
message = String(err);
}
const sanitized = sanitizeErrorMessage(message);
if (sanitized) return sanitized;
}
return null;
}
/**
* Convert OpenAI-style SSE chunks into a single non-streaming JSON response.
* Used as a fallback when upstream returns text/event-stream for stream=false.
*/
function readSSEEvents(rawSSE) {
const lines = String(rawSSE || "").split("\n");
const events = [];
let currentEvent = "";
let currentData = [];
const flush = () => {
if (currentData.length === 0) {
currentEvent = "";
return;
}
const payload = currentData.join("\n").trim();
currentData = [];
if (!payload || payload === "[DONE]") {
currentEvent = "";
return;
}
try {
const data = JSON.parse(payload);
if (
currentEvent &&
data &&
typeof data === "object" &&
!Array.isArray(data) &&
typeof data.type !== "string"
) {
data.type = currentEvent;
}
events.push({
event: currentEvent || undefined,
data,
});
} catch {
// Ignore malformed SSE events and continue best-effort parsing.
}
currentEvent = "";
};
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "");
if (line.trim() === "") {
flush();
continue;
}
if (line.startsWith("event:")) {
// Some relays omit the blank separator between Claude events. Flush the
// previous event before accepting the next event name.
if (currentData.length > 0) flush();
currentEvent = line.slice(6).trim();
continue;
}
if (line.startsWith("data:")) {
const dataLine = line.slice(5).trimStart();
if (dataLine.trim() === "[DONE]") {
flush();
currentEvent = "";
continue;
}
currentData.push(dataLine);
}
}
flush();
return events;
}
function toRecord(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function toString(value, fallback = "") {
return typeof value === "string" ? value : fallback;
}
function toNumber(value, fallback = 0) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
const lines = String(rawSSE || "").split("\n");
const chunks = [];
let sawChoices = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const parsed = JSON.parse(payload);
if (Array.isArray(parsed?.choices)) {
sawChoices = true;
}
chunks.push(parsed);
} catch {
// Ignore malformed SSE lines and continue best-effort parsing.
}
}
if (chunks.length === 0 || !sawChoices) return null;
const first = chunks[0];
const contentParts = [];
const reasoningParts = [];
type AccumulatedToolCall = {
id: string | null;
index: number;
type: string;
function: { name: string; arguments: string };
};
const accumulatedToolCalls = new Map<string, AccumulatedToolCall>();
let unknownToolCallSeq = 0;
let finishReason = "stop";
let usage = null;
const getToolCallKey = (toolCall: Record<string, unknown>) => {
if (Number.isInteger(toolCall?.index)) return `idx:${toolCall.index}`;
if (toolCall?.id != null) return `id:${String(toolCall.id)}`;
unknownToolCallSeq += 1;
return `seq:${unknownToolCallSeq}`;
};
for (const chunk of chunks) {
const choice = chunk?.choices?.[0];
const delta = choice?.delta || {};
if (typeof delta.content === "string" && delta.content.length > 0) {
contentParts.push(delta.content);
}
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
reasoningParts.push(delta.reasoning_content);
}
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
if (
typeof delta.reasoning === "string" &&
delta.reasoning.length > 0 &&
!delta.reasoning_content
) {
reasoningParts.push(delta.reasoning);
}
// T18: Accumulate tool calls correctly across streamed chunks
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const key = getToolCallKey(tc);
const existing = accumulatedToolCalls.get(key);
const deltaArgs = typeof tc?.function?.arguments === "string" ? tc.function.arguments : "";
if (!existing) {
accumulatedToolCalls.set(key, {
id: tc?.id != null ? String(tc.id) : null,
index: Number.isInteger(tc?.index) ? tc.index : accumulatedToolCalls.size,
type: tc?.type || "function",
function: {
name: tc?.function?.name || "unknown",
arguments: deltaArgs,
},
});
} else {
existing.id = existing.id || (tc?.id != null ? String(tc.id) : null);
if (!Number.isInteger(existing.index) && Number.isInteger(tc?.index)) {
existing.index = tc.index;
}
if (tc?.function?.name && !existing.function?.name) {
existing.function = existing.function || {};
existing.function.name = tc.function.name;
}
existing.function = existing.function || {};
existing.function.arguments = appendToolCallArgumentDelta(
existing.function.arguments,
deltaArgs
);
accumulatedToolCalls.set(key, existing);
}
}
}
if (choice?.finish_reason) {
finishReason = choice.finish_reason;
}
if (chunk?.usage && typeof chunk.usage === "object") {
usage = chunk.usage;
}
}
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : "";
const joinedReasoning = reasoningParts.length > 0 ? reasoningParts.join("").trim() : null;
const message: Record<string, unknown> = {
role: "assistant",
content: joinedContent,
};
if (joinedReasoning) {
message.reasoning_content = joinedReasoning;
}
const finalToolCalls = [...accumulatedToolCalls.values()].filter(Boolean).sort((a, b) => {
const ai = Number.isInteger(a?.index) ? a.index : 0;
const bi = Number.isInteger(b?.index) ? b.index : 0;
return ai - bi;
});
if (finalToolCalls.length > 0) {
finishReason = "tool_calls"; // T18 normalization
message.tool_calls = finalToolCalls;
}
const result: Record<string, unknown> = {
id: first.id != null ? String(first.id) : `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: first.created || Math.floor(Date.now() / 1000),
model: first.model || fallbackModel || "unknown",
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
};
if (usage) {
result.usage = usage;
}
return result;
}
/**
* Convert Claude-style SSE events into a single non-streaming message object.
* Used when Claude-compatible upstreams stream even for stream=false.
*/
export function parseSSEToClaudeResponse(rawSSE, fallbackModel) {
const payloads = readSSEEvents(rawSSE)
.map((event) => toRecord(event.data))
.filter((payload) => Object.keys(payload).length > 0);
if (payloads.length === 0) return null;
const blocks = new Map();
const usage = {};
let messageId = "";
let model = fallbackModel || "claude";
let role = "assistant";
let stopReason = "end_turn";
let stopSequence = null;
let sawClaudeEvent = false;
const mergeUsage = (incoming) => {
const usageRecord = toRecord(incoming);
for (const [key, value] of Object.entries(usageRecord)) {
if (typeof value === "number" && Number.isFinite(value)) {
usage[key] = value;
} else if (value && typeof value === "object" && !Array.isArray(value)) {
usage[key] = { ...toRecord(usage[key]), ...toRecord(value) };
} else if (typeof value === "string" && value.trim().length > 0) {
usage[key] = value;
}
}
};
const tryParseJson = (raw) => {
try {
return JSON.parse(raw);
} catch {
return raw;
}
};
for (const payload of payloads) {
const eventType = toString(payload.type);
if (eventType === "message_start") {
sawClaudeEvent = true;
const message = toRecord(payload.message);
messageId = toString(message.id, messageId || `msg_${Date.now()}`);
model = toString(message.model, model);
role = toString(message.role, role);
mergeUsage(message.usage);
continue;
}
if (eventType === "content_block_start") {
sawClaudeEvent = true;
const index = toNumber(payload.index, blocks.size);
const contentBlock = toRecord(payload.content_block);
const blockType = toString(contentBlock.type);
if (blockType === "thinking") {
blocks.set(index, {
type: "thinking",
index,
thinking: toString(contentBlock.thinking),
signature: toString(contentBlock.signature) || undefined,
});
} else if (blockType === "tool_use") {
blocks.set(index, {
type: "tool_use",
index,
id: toString(contentBlock.id, `toolu_${Date.now()}_${index}`),
name: toString(contentBlock.name),
input: contentBlock.input ?? {},
inputJson: "",
});
} else {
blocks.set(index, {
type: "text",
index,
text: toString(contentBlock.text),
});
}
continue;
}
if (eventType === "content_block_delta") {
sawClaudeEvent = true;
const index = toNumber(payload.index, 0);
const delta = toRecord(payload.delta);
const deltaType = toString(delta.type);
const existing = blocks.get(index);
if (deltaType === "input_json_delta") {
const toolUse =
existing && existing.type === "tool_use"
? existing
: {
type: "tool_use",
index,
id: `toolu_${Date.now()}_${index}`,
name: "",
input: {},
inputJson: "",
};
toolUse.inputJson += toString(delta.partial_json);
blocks.set(index, toolUse);
continue;
}
const isThinkingDelta = deltaType === "thinking_delta" || typeof delta.thinking === "string";
const isSignatureDelta =
deltaType === "signature_delta" || typeof delta.signature === "string";
if (isThinkingDelta || isSignatureDelta) {
const thinking =
existing && existing.type === "thinking"
? existing
: { type: "thinking", index, thinking: "", signature: undefined };
if (isThinkingDelta) thinking.thinking += toString(delta.thinking);
const signature = toString(delta.signature);
if (signature) thinking.signature = `${thinking.signature || ""}${signature}`;
blocks.set(index, thinking);
continue;
}
const textBlock =
existing && existing.type === "text"
? existing
: {
type: "text",
index,
text: "",
};
textBlock.text += toString(delta.text);
blocks.set(index, textBlock);
continue;
}
if (eventType === "message_delta") {
sawClaudeEvent = true;
const delta = toRecord(payload.delta);
stopReason = toString(delta.stop_reason, stopReason);
stopSequence =
typeof delta.stop_sequence === "string" ? String(delta.stop_sequence) : stopSequence;
mergeUsage(payload.usage);
continue;
}
mergeUsage(payload.usage);
}
if (!sawClaudeEvent) return null;
const content = [];
for (const block of [...blocks.values()].sort((a, b) => a.index - b.index)) {
if (block.type === "text") {
if (block.text) content.push({ type: "text", text: block.text });
continue;
}
if (block.type === "thinking") {
const hasSignature = typeof block.signature === "string" && block.signature.length > 0;
if (block.thinking || hasSignature) {
content.push({
type: "thinking",
thinking: block.thinking || "",
...(hasSignature ? { signature: block.signature } : {}),
});
}
continue;
}
const input = block.inputJson.trim().length > 0 ? tryParseJson(block.inputJson) : block.input;
content.push({ type: "tool_use", id: block.id, name: block.name, input });
}
return {
id: messageId || `msg_${Date.now()}`,
type: "message",
role,
model,
content,
stop_reason: stopReason,
...(stopSequence ? { stop_sequence: stopSequence } : {}),
...(Object.keys(usage).length > 0 ? { usage } : {}),
};
}
/**
* Convert Responses API SSE events into a single non-streaming response object.
* Expects events such as response.created / response.in_progress / response.completed.
*/
const RESPONSES_TERMINAL_EVENT_TYPES = new Set([
"response.completed",
"response.done",
"response.cancelled",
"response.canceled",
"response.failed",
"response.incomplete",
]);
function toOutputIndex(value) {
if (typeof value === "number" && Number.isInteger(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isInteger(parsed)) return parsed;
}
return null;
}
function toIdString(value) {
return value === null || value === undefined ? "" : String(value);
}
function cloneResponseItem(item) {
const record = toRecord(item);
return {
...record,
id: record.id != null ? String(record.id) : record.id,
call_id: record.call_id != null ? String(record.call_id) : record.call_id,
...(Array.isArray(record.content)
? {
content: record.content.map((contentPart) => {
const part = toRecord(contentPart);
return { ...part };
}),
}
: {}),
...(Array.isArray(record.summary)
? {
summary: record.summary.map((summaryPart) => {
const part = toRecord(summaryPart);
return { ...part };
}),
}
: {}),
};
}
function ensureResponsesMessageItem(outputItems, outputIndex) {
const existing = outputItems.get(outputIndex);
if (existing?.type === "message") return existing;
const next = {
...(existing && typeof existing === "object" ? existing : {}),
id: existing?.id != null ? String(existing.id) : `msg_${Date.now()}_${outputIndex}`,
type: "message",
role: "assistant",
content: Array.isArray(existing?.content)
? existing.content.map((contentPart) => ({ ...toRecord(contentPart) }))
: [{ type: "output_text", annotations: [], text: "" }],
};
if (next.content.length === 0) {
next.content.push({ type: "output_text", annotations: [], text: "" });
}
outputItems.set(outputIndex, next);
return next;
}
function ensureResponsesReasoningItem(outputItems, outputIndex, itemId) {
const existing = outputItems.get(outputIndex);
if (existing?.type === "reasoning") return existing;
const next = {
...(existing && typeof existing === "object" ? existing : {}),
id:
itemId ||
(existing?.id != null ? String(existing.id) : null) ||
`rs_${Date.now()}_${outputIndex}`,
type: "reasoning",
summary: Array.isArray(existing?.summary)
? existing.summary.map((summaryPart) => ({ ...toRecord(summaryPart) }))
: [{ type: "summary_text", text: "" }],
};
if (next.summary.length === 0) {
next.summary.push({ type: "summary_text", text: "" });
}
outputItems.set(outputIndex, next);
return next;
}
function ensureResponsesFunctionCallItem(outputItems, outputIndex, itemId, callId, name) {
const existing = outputItems.get(outputIndex);
const normalizedItemId = toIdString(itemId);
const normalizedCallId = toIdString(callId);
const existingId = existing?.id != null ? String(existing.id) : "";
const existingCallId = existing?.call_id != null ? String(existing.call_id) : "";
if (existing?.type === "function_call") {
if (existing.call_id != null) existing.call_id = String(existing.call_id);
if (existing.id != null) existing.id = String(existing.id);
if (normalizedCallId && !existing.call_id) existing.call_id = normalizedCallId;
if (name && !existing.name) existing.name = name;
if (normalizedItemId && !existing.id) existing.id = normalizedItemId;
return existing;
}
const next = {
...(existing && typeof existing === "object" ? existing : {}),
id:
normalizedItemId || existingId || `fc_${normalizedCallId || `${Date.now()}_${outputIndex}`}`,
type: "function_call",
call_id: normalizedCallId || existingCallId || "",
name: name || existing?.name || "",
arguments: typeof existing?.arguments === "string" ? existing.arguments : "",
};
outputItems.set(outputIndex, next);
return next;
}
function mergeResponseItems(existing, incoming) {
const next = cloneResponseItem(incoming);
if (!existing || typeof existing !== "object") return next;
return {
...existing,
...next,
...(Array.isArray(next.content)
? {
content: next.content,
}
: {}),
...(Array.isArray(next.summary)
? {
summary: next.summary,
}
: {}),
};
}
export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
const lines = String(rawSSE || "").split("\n");
const events = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const parsed = JSON.parse(payload);
const record = toRecord(parsed);
if (Object.keys(record).length > 0) {
events.push(record);
}
} catch {
// Ignore malformed lines and continue best-effort parsing.
}
}
if (events.length === 0) return null;
let terminalResponse = null;
let terminalEventType = "";
let latestResponse = null;
const outputItems = new Map();
for (const evt of events) {
const eventType = toString(evt?.type);
const outputIndex = toOutputIndex(evt?.output_index);
const item = toRecord(evt?.item);
if (outputIndex !== null && eventType === "response.output_item.added") {
outputItems.set(outputIndex, cloneResponseItem(item));
}
if (outputIndex !== null && eventType === "response.output_item.done") {
const existing = outputItems.get(outputIndex);
outputItems.set(outputIndex, mergeResponseItems(existing, item));
}
if (outputIndex !== null && eventType === "response.output_text.delta") {
const messageItem = ensureResponsesMessageItem(outputItems, outputIndex);
const content = Array.isArray(messageItem.content) ? messageItem.content : [];
const firstPart =
content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] };
firstPart.type = firstPart.type || "output_text";
firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : [];
firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`;
content[0] = firstPart;
messageItem.content = content;
}
if (outputIndex !== null && eventType === "response.output_text.done") {
const messageItem = ensureResponsesMessageItem(outputItems, outputIndex);
const content = Array.isArray(messageItem.content) ? messageItem.content : [];
const firstPart =
content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] };
firstPart.type = firstPart.type || "output_text";
firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : [];
firstPart.text = toString(evt.text, toString(firstPart.text));
content[0] = firstPart;
messageItem.content = content;
}
if (outputIndex !== null && eventType === "response.reasoning_summary_text.delta") {
const reasoningItem = ensureResponsesReasoningItem(
outputItems,
outputIndex,
toIdString(evt.item_id)
);
const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : [];
const firstPart =
summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" };
firstPart.type = firstPart.type || "summary_text";
firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`;
summary[0] = firstPart;
reasoningItem.summary = summary;
}
if (outputIndex !== null && eventType === "response.reasoning_summary_text.done") {
const reasoningItem = ensureResponsesReasoningItem(
outputItems,
outputIndex,
toIdString(evt.item_id)
);
const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : [];
const firstPart =
summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" };
firstPart.type = firstPart.type || "summary_text";
firstPart.text = toString(evt.text, toString(firstPart.text));
summary[0] = firstPart;
reasoningItem.summary = summary;
}
if (outputIndex !== null && eventType === "response.function_call_arguments.delta") {
const functionCallItem = ensureResponsesFunctionCallItem(
outputItems,
outputIndex,
toIdString(evt.item_id),
"",
""
);
functionCallItem.arguments = `${toString(functionCallItem.arguments)}${toString(evt.delta)}`;
}
if (outputIndex !== null && eventType === "response.function_call_arguments.done") {
const functionCallItem = ensureResponsesFunctionCallItem(
outputItems,
outputIndex,
toIdString(evt.item_id),
"",
""
);
functionCallItem.arguments = toString(evt.arguments, toString(functionCallItem.arguments));
}
if (RESPONSES_TERMINAL_EVENT_TYPES.has(eventType) && evt.response) {
terminalResponse = evt.response;
terminalEventType = eventType;
}
if (evt?.response && typeof evt.response === "object") {
latestResponse = evt.response;
} else if (evt?.object === "response") {
latestResponse = evt;
}
}
const picked = terminalResponse || latestResponse;
if (!picked || typeof picked !== "object") return null;
const reconstructedOutput = [...outputItems.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, item]) => item)
.filter((item) => item && typeof item === "object");
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
// #3948 — A Responses-API terminal snapshot (`response.completed`) can carry a
// non-empty `output` that LACKS the assistant message item (e.g. only a
// `reasoning` item) even though the streamed `output_text` deltas reconstructed
// a full message. Preferring such a textless terminal output drops the
// assistant text → empty content on `stream:false` (n8n combo). When the
// terminal output has no message item but the reconstructed delta output does,
// use the reconstructed output (a superset carrying the message). The terminal
// snapshot still wins whenever it already contains the message item.
const outputHasMessage = (items: unknown[]) =>
items.some((item) => toRecord(item).type === "message");
const chosenOutput =
pickedOutput.length > 0 &&
!outputHasMessage(pickedOutput) &&
outputHasMessage(reconstructedOutput)
? reconstructedOutput
: pickedOutput.length > 0
? pickedOutput
: reconstructedOutput;
const statusFallback =
terminalEventType === "response.cancelled"
? "cancelled"
: terminalEventType === "response.canceled"
? "canceled"
: terminalEventType === "response.failed"
? "failed"
: terminalEventType === "response.incomplete"
? "incomplete"
: terminalResponse
? "completed"
: "in_progress";
return {
id: picked.id != null ? String(picked.id) : `resp_${Date.now()}`,
object: picked.object || "response",
model: picked.model || fallbackModel || "unknown",
output: chosenOutput.map((item) => {
const record = toRecord(item);
return {
...record,
id: record.id != null ? String(record.id) : record.id,
call_id: record.call_id != null ? String(record.call_id) : record.call_id,
};
}),
usage: picked.usage || null,
status: picked.status || statusFallback,
created_at: picked.created_at || Math.floor(Date.now() / 1000),
metadata: picked.metadata || {},
};
}