mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
* feat(dashboard): add RADAR_ENABLED flag (default off) * feat(db): radar feed cache + settings with encrypted supporter key * feat(radar): signed feed sync with pinned key and version floor - feedSchema.ts: Zod v4 schema mirroring the server feed format (discriminated union on budget.kind, enum constraints, etc.) - pinnedKeys.ts: Ed25519 SPKI-DER pinned key + env override for forks - verify.ts: signature verification over exact wire bytes, never throws - sync.ts: full download/verify/validate/cache pipeline with injectable deps, feature-flag gate, opt-in gate, version floor (numeric compare), and sanitized error reasons (no stack traces) - 40 tests covering: contract hash, key handling, sig verification, schema validation, version compare, all sync paths (disabled, opt_out, invalid_signature, invalid_schema, stale, updated, error), auth header injection, and cache-untouched assertions for every failure mode * feat(radar): read-time overlay merge rules over the free catalog Pure function applyFeed() merges the cached Radar feed over the static baseline catalog at read time, honoring 4 rules: 1. Feed never overwrites a local override field. 2. enabled:false disables the entry with disabledBy:"radar" provenance. 3. User-added entry NOT in the feed survives untouched. 4. User deletion tombstone prevents feed resurrection. getRadarCatalog() accessor in index.ts: flag off / no cache / corrupt payload all fall back to baseline. Valid cache applies the overlay and returns feed metadata (version, tier, fetchedAt). TDD: 19 tests (4 rules + dedup + origin + accessor flag/cache/corrupt/ valid/bad-feed + baselineToMergedEntries converter). * feat(dashboard): radar catalog and guided setup screens - API routes: GET /api/radar/catalog, POST /api/radar/sync, POST /api/radar/settings - All gated on RADAR_ENABLED flag (404 when off) - Error responses via buildErrorBody(), never raw stack/message - Settings never echoes clear supporter key (masked omr_****<last4>) - Sync delegates to syncRadar() server-side, never proxies feed URL - Dashboard pages: - /dashboard/radar: 4 states (flag off, opt-in pending, empty, populated) - /dashboard/radar/setup?provider=X: guided setup with steps, key URL, test connection - Uses existing Card component and next-intl patterns - Sidebar: radar entry in costs group with icon - i18n: pt-BR and en keys for radarPage and radarSetupPage namespaces - Tests: - radar-api-routes.test.ts: 11 tests (flag-off 404, flag-on shape, error sanitization) - radar-page-state.test.ts: 5 tests (pure state logic) - All 90 radar tests pass (including prior 74) * docs(radar): module doc and flag-off inertia test Add docs/frameworks/RADAR.md covering the flag gate, the separate data-sync opt-in and privacy promise, the Ed25519 signature/pinned-key security model, tiers, the read-time overlay merge rules, and the self-hosting env vars — plus index entries in CLAUDE.md/AGENTS.md/docs/README.md/REPOSITORY_MAP.md. Document RADAR_FEED_URL and RADAR_FEED_PUBKEY in .env.example and docs/reference/ENVIRONMENT.md to satisfy check:env-doc-sync, which was failing on this branch since the sync.ts commit added the reads. Add tests/unit/radar-inertia.test.ts as the single canonical place asserting the "RADAR_ENABLED off => zero behavioral delta" claim end to end: the three /api/radar/* routes 404, the flag resolves to the definition default with no override, getRadarCatalog() returns exactly the baseline without touching the cache, and computeFreeModelTotals() keeps its pinned values with the Radar module imported alongside it. * fix(db): renumber radar migration to 135 after collision with 134 The base branch introduced 134_proxy_logs_egress_ip while this branch carried 134_radar_cache_settings; the migration runner rejects duplicate numeric prefixes. This migration has never been applied to a real database (the PR is unmerged), so no retroactive isSchemaAlreadyApplied guard is needed. * i18n(radar): translate radar catalog and setup strings to all locales The UI-coverage ratchet measures (present - placeholder) / total_en, so the __MISSING__ sentinels that i18n:sync-ui writes do not count as covered — only real translations restore the metric. Scoped to this PR's namespaces (radarPage, radarSetupPage, sidebar.radar*) instead of a bulk sync, which would have pulled ~978 unrelated pending keys into this diff. Placeholders and code identifiers verified preserved across all 1682 strings. * fix(radar): trust the served-tier header instead of the signed body field The signed feed body always carries tier:"live" by design (one signed artifact per version — rewriting the field server-side per request would break the exact-bytes Ed25519 signature). The server now returns the tier ACTUALLY served via the x-omniroute-feed-tier response header, so free users on a delayed community snapshot no longer see "Ao vivo (tempo real)" in the UI. sync.ts now reads and validates that header (falling back to the body's tier only when the header is absent or holds an unrecognized value) and stores the served tier in the cache; index.ts already surfaces cache.tier to the UI unchanged. * test(combo): shorten an assert message that exceeded the line limit The assertion added by #9507 was 104 chars, so prettier reformatted it into five lines on the next commit that touched the file, pushing it past its frozen size (3449) and failing check:file-size. The message is shortened (the issue reference stays in the comment directly above); the assertion itself is unchanged, and the file is back to 3448 lines and prettier-clean. * i18n(radar): use the canonical zh-TW glossary terms The machine translation produced retired renderings the glossary gate blocks: 供應商 for provider (canonical 提供者) and 文檔 for documentation (canonical 文件). Fixed across the 11 affected radar strings; tests/unit/i18n-glossary-consistency-check.test.ts is back to 17/17. * fix(radar): point the default feed URL at the domain that exists radar.omniroute.dev was a placeholder for a domain that was never registered, so an out-of-the-box sync would fail DNS resolution for every user. The live feed is served from radar.omniroute.online (the subdomain the design always specified), now behind Cloudflare TLS. Forks still override it via RADAR_FEED_URL. --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
835 lines
27 KiB
TypeScript
835 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.name = tc.function.name;
|
|
}
|
|
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 : [];
|
|
// #9500 — respect summary_index: each segment is a distinct summary_text
|
|
// part. Place deltas at summary[summary_index] (growing the array) so
|
|
// segments are preserved for later "\n\n" joining on the non-stream path,
|
|
// instead of overwriting summary[0] regardless of index.
|
|
const summaryIndex = typeof evt.summary_index === "number" ? evt.summary_index : 0;
|
|
const part =
|
|
summary[summaryIndex] && typeof summary[summaryIndex] === "object"
|
|
? { ...toRecord(summary[summaryIndex]) }
|
|
: { type: "summary_text", text: "" };
|
|
part.type = part.type || "summary_text";
|
|
part.text = `${toString(part.text)}${toString(evt.delta)}`;
|
|
summary[summaryIndex] = part;
|
|
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 : [];
|
|
// #9500 — respect summary_index on the terminal done event too.
|
|
const summaryIndex = typeof evt.summary_index === "number" ? evt.summary_index : 0;
|
|
const part =
|
|
summary[summaryIndex] && typeof summary[summaryIndex] === "object"
|
|
? { ...toRecord(summary[summaryIndex]) }
|
|
: { type: "summary_text", text: "" };
|
|
part.type = part.type || "summary_text";
|
|
part.text = toString(evt.text, toString(part.text));
|
|
summary[summaryIndex] = part;
|
|
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 || {},
|
|
};
|
|
}
|