mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* fix(stream): reconcile encrypted Codex reasoning visibility without mutating upstream item Resolves the collision between two open PRs on ensureVisibleResponsesReasoningSummary: #7095 (xz-dev) found that chat clients see nothing when Codex exposes reasoning only as encrypted_content, and added a visible placeholder — but did so by mutating item.summary in place. #7176 (JxnLexn) found that same mutation corrupts the forwarded response item, discarding the encrypted_content shape Codex needs for follow-up requests, and removed the mutation — but that also silently dropped the placeholder, so chat clients went back to seeing nothing. The mutation existed only so a later line could read the summary text back off the same item. getVisibleResponsesReasoningSummaryText() computes that text without touching the item, so: - synthetic response.reasoning_summary_text.delta / .part.done events still carry the placeholder for chat clients (#7095's goal), and - the forwarded response.output_item.done payload keeps its original encrypted_content intact with no fabricated summary field (#7176's goal). Applied at both call sites #7095 identified: the native Responses passthrough in stream.ts/passthroughTailProcessor.ts, and the Responses-to-Chat-Completions translator in openai-responses.ts. Closes #7095, closes #7176. Co-authored-by: Xiangzhe <xiangzhedev@gmail.com> Co-authored-by: Jan Leon <Jan.gaschler@gmail.com> * test(stream): guard the encrypted-reasoning mutation via the completed backfill path The output_item.done line is echoed verbatim on the wire, so a re-introduced item.summary mutation does NOT surface in that event — verified by re-injecting the mutation, which left the existing assertion green. The mutation does surface in the response.completed snapshot, where the captured reasoning item is re-serialized when upstream sends an empty output (store: false). Adds that case, which fails as expected when the mutation is re-introduced, making the #7176 half of the reconciliation an enforced regression guard rather than an incidental property of the current code path. Co-authored-by: Xiangzhe <xiangzhedev@gmail.com> Co-authored-by: Jan Leon <Jan.gaschler@gmail.com> --------- Co-authored-by: Xiangzhe <xiangzhedev@gmail.com> Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import { extractUsage } from "./usageTracking.ts";
|
|
import { parseSSEDataPayload } from "./streamHelpers.ts";
|
|
import {
|
|
backfillResponsesCompletedOutput,
|
|
normalizeResponsesSseIds,
|
|
pushUniqueResponsesOutputItems,
|
|
stringifyIdValue,
|
|
stripResponsesLifecycleEcho,
|
|
} from "./responsesStreamHelpers.ts";
|
|
import { getAnyReasoningValue } from "./reasoningFields.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
type EventPrefixBuffer = {
|
|
eventType: () => string;
|
|
flush: () => string;
|
|
prefixData: (output: string, line: string) => string;
|
|
remember: (line: string) => void;
|
|
};
|
|
|
|
export type PassthroughTailProcessorContext = {
|
|
getSkipPassthroughEvent: () => boolean;
|
|
setSkipPassthroughEvent: (value: boolean) => void;
|
|
clearPendingPassthroughEvent: () => void;
|
|
shouldAbortOnClaudeLifecycle: (payload: unknown) => boolean;
|
|
emitClaudeEmptyStreamErrorAndAbort: () => void;
|
|
isClaudeEventPayload: (payload: unknown) => boolean;
|
|
updateClaudeEmptyResponseLifecycle: (payload: unknown) => void;
|
|
passthroughEventPrefix: EventPrefixBuffer;
|
|
emitConvertedOutput: (output: string) => void;
|
|
pushProviderPayload: (payload: unknown) => void;
|
|
pushClientPayload: (payload: unknown) => void;
|
|
setPassthroughResponsesId: (value: string) => void;
|
|
setUsage: (value: unknown) => void;
|
|
addTotalContentLength: (value: number) => void;
|
|
appendPassthroughContent: (value: string) => void;
|
|
appendPassthroughReasoning: (value: string) => void;
|
|
getResponsesReasoningKey: (payload: Record<string, unknown>) => string | null;
|
|
markResponsesReasoningSummarySeen: (key: string) => void;
|
|
emitSyntheticResponsesReasoningSummary: (payload: Record<string, unknown>) => void;
|
|
passthroughResponsesOutputItems: unknown[];
|
|
passthroughResponsesPendingFunctionCalls: Map<string, JsonRecord>;
|
|
getPassthroughResponsesCurrentFunctionCallKey: () => string | null;
|
|
setPassthroughResponsesCurrentFunctionCallKey: (value: string | null) => void;
|
|
hasPassthroughToolCalls: () => boolean;
|
|
toResponsesCompletedWithToolCalls: (parsed: JsonRecord) => JsonRecord;
|
|
};
|
|
|
|
function asRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
|
}
|
|
|
|
function getFunctionCallPendingKey(item: JsonRecord | null): string | null {
|
|
if (!item) return null;
|
|
if (typeof item.id === "string") return item.id;
|
|
if (typeof item.call_id === "string") return item.call_id;
|
|
return null;
|
|
}
|
|
|
|
function handleResponsesTailPayload(
|
|
parsed: JsonRecord,
|
|
output: string,
|
|
context: PassthroughTailProcessorContext
|
|
): string {
|
|
const responsesIdsNormalized = normalizeResponsesSseIds(parsed);
|
|
const parsedResponse = asRecord(parsed.response);
|
|
const responseId =
|
|
(parsedResponse ? stringifyIdValue(parsedResponse.id) : null) ||
|
|
stringifyIdValue(parsed.response_id);
|
|
if (responseId) {
|
|
context.setPassthroughResponsesId(responseId);
|
|
}
|
|
|
|
const extracted = extractUsage(parsed);
|
|
if (extracted) {
|
|
context.setUsage(extracted);
|
|
}
|
|
|
|
if (typeof parsed.delta === "string") {
|
|
context.addTotalContentLength(parsed.delta.length);
|
|
}
|
|
if (parsed.type === "response.output_text.delta" && typeof parsed.delta === "string") {
|
|
context.appendPassthroughContent(parsed.delta);
|
|
}
|
|
if (
|
|
parsed.type === "response.reasoning_summary_text.delta" ||
|
|
parsed.type === "response.reasoning_summary_text.done" ||
|
|
parsed.type === "response.reasoning_summary_part.done"
|
|
) {
|
|
const reasoningKey = context.getResponsesReasoningKey(parsed);
|
|
if (reasoningKey) {
|
|
context.markResponsesReasoningSummarySeen(reasoningKey);
|
|
}
|
|
}
|
|
if (
|
|
parsed.type === "response.output_item.added" &&
|
|
asRecord(parsed.item).type === "function_call"
|
|
) {
|
|
const item = { ...(parsed.item as JsonRecord) };
|
|
const pendingKey = getFunctionCallPendingKey(item);
|
|
if (pendingKey) {
|
|
if (typeof item.arguments !== "string") {
|
|
item.arguments = "";
|
|
}
|
|
context.passthroughResponsesPendingFunctionCalls.set(pendingKey, item);
|
|
context.setPassthroughResponsesCurrentFunctionCallKey(pendingKey);
|
|
}
|
|
}
|
|
if (parsed.type === "response.function_call_arguments.delta") {
|
|
const pendingKey =
|
|
typeof parsed.item_id === "string"
|
|
? parsed.item_id
|
|
: context.getPassthroughResponsesCurrentFunctionCallKey();
|
|
const pending = pendingKey
|
|
? context.passthroughResponsesPendingFunctionCalls.get(pendingKey)
|
|
: undefined;
|
|
if (pending && typeof parsed.delta === "string") {
|
|
const previousArgs = typeof pending.arguments === "string" ? pending.arguments : "";
|
|
pending.arguments = previousArgs + parsed.delta;
|
|
}
|
|
}
|
|
if (parsed.type === "response.function_call_arguments.done") {
|
|
const pendingKey =
|
|
typeof parsed.item_id === "string"
|
|
? parsed.item_id
|
|
: context.getPassthroughResponsesCurrentFunctionCallKey();
|
|
const pending = pendingKey
|
|
? context.passthroughResponsesPendingFunctionCalls.get(pendingKey)
|
|
: undefined;
|
|
if (pending) {
|
|
if (typeof parsed.arguments === "string") {
|
|
pending.arguments = parsed.arguments;
|
|
}
|
|
pushUniqueResponsesOutputItems(context.passthroughResponsesOutputItems, [pending]);
|
|
}
|
|
}
|
|
if (parsed.type === "response.output_item.done" && parsed.item) {
|
|
context.emitSyntheticResponsesReasoningSummary(parsed);
|
|
pushUniqueResponsesOutputItems(context.passthroughResponsesOutputItems, [parsed.item]);
|
|
const item = asRecord(parsed.item);
|
|
if (item.type === "function_call") {
|
|
const pendingKey = getFunctionCallPendingKey(item);
|
|
if (pendingKey) {
|
|
context.passthroughResponsesPendingFunctionCalls.delete(pendingKey);
|
|
if (context.getPassthroughResponsesCurrentFunctionCallKey() === pendingKey) {
|
|
context.setPassthroughResponsesCurrentFunctionCallKey(null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
parsed.type === "response.completed" &&
|
|
Array.isArray(asRecord(parsed.response).output) &&
|
|
(asRecord(parsed.response).output as unknown[]).length > 0
|
|
) {
|
|
pushUniqueResponsesOutputItems(
|
|
context.passthroughResponsesOutputItems,
|
|
asRecord(parsed.response).output as unknown[]
|
|
);
|
|
}
|
|
if (
|
|
parsed.type === "response.completed" &&
|
|
context.passthroughResponsesPendingFunctionCalls.size > 0
|
|
) {
|
|
pushUniqueResponsesOutputItems(context.passthroughResponsesOutputItems, [
|
|
...context.passthroughResponsesPendingFunctionCalls.values(),
|
|
]);
|
|
context.passthroughResponsesPendingFunctionCalls.clear();
|
|
context.setPassthroughResponsesCurrentFunctionCallKey(null);
|
|
}
|
|
|
|
const textualToolCallBackfilled =
|
|
parsed.type === "response.completed" && context.hasPassthroughToolCalls();
|
|
const outputPayload = textualToolCallBackfilled
|
|
? context.toResponsesCompletedWithToolCalls(parsed)
|
|
: parsed;
|
|
const stripped = stripResponsesLifecycleEcho(outputPayload);
|
|
const backfilled = backfillResponsesCompletedOutput(
|
|
outputPayload,
|
|
context.passthroughResponsesOutputItems
|
|
);
|
|
|
|
if (stripped || backfilled || textualToolCallBackfilled || responsesIdsNormalized) {
|
|
output = `data: ${JSON.stringify(outputPayload)}\n\n`;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function handleOpenAiTailPayload(parsed: JsonRecord, context: PassthroughTailProcessorContext) {
|
|
const firstChoice = Array.isArray(parsed.choices)
|
|
? (parsed.choices[0] as JsonRecord | undefined)
|
|
: undefined;
|
|
const delta = asRecord(firstChoice?.delta);
|
|
if (typeof delta.content === "string") {
|
|
context.appendPassthroughContent(delta.content);
|
|
context.addTotalContentLength(delta.content.length);
|
|
}
|
|
const reasoningDelta = getAnyReasoningValue(delta);
|
|
if (reasoningDelta) {
|
|
context.appendPassthroughReasoning(reasoningDelta);
|
|
}
|
|
}
|
|
|
|
export function processBufferedPassthroughLine(
|
|
line: string,
|
|
context: PassthroughTailProcessorContext
|
|
): boolean {
|
|
const trimmed = line.trim();
|
|
|
|
if (context.getSkipPassthroughEvent()) {
|
|
if (!trimmed) {
|
|
context.setSkipPassthroughEvent(false);
|
|
context.clearPendingPassthroughEvent();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (/^event:\s*keepalive\b/i.test(trimmed)) {
|
|
context.setSkipPassthroughEvent(true);
|
|
context.clearPendingPassthroughEvent();
|
|
return false;
|
|
}
|
|
|
|
if (/^event:/i.test(trimmed)) {
|
|
const eventType = trimmed.replace(/^event:\s*/i, "");
|
|
if (context.shouldAbortOnClaudeLifecycle({ type: eventType })) {
|
|
context.emitClaudeEmptyStreamErrorAndAbort();
|
|
return true;
|
|
}
|
|
context.passthroughEventPrefix.remember(line);
|
|
return false;
|
|
}
|
|
|
|
if (/^(?::|id:|retry:)/i.test(trimmed)) {
|
|
context.passthroughEventPrefix.remember(line);
|
|
return false;
|
|
}
|
|
|
|
if (!trimmed) {
|
|
const pendingOutput = context.passthroughEventPrefix.flush();
|
|
if (pendingOutput) {
|
|
context.emitConvertedOutput(pendingOutput);
|
|
}
|
|
context.clearPendingPassthroughEvent();
|
|
return false;
|
|
}
|
|
|
|
if (!trimmed.startsWith("data:")) {
|
|
context.passthroughEventPrefix.remember(line);
|
|
return false;
|
|
}
|
|
|
|
const parsedPassthroughData = parseSSEDataPayload(trimmed.slice(5), {
|
|
eventType: context.passthroughEventPrefix.eventType(),
|
|
});
|
|
if (parsedPassthroughData?.done === true) {
|
|
return false;
|
|
}
|
|
|
|
let output =
|
|
line.startsWith("data:") && !line.startsWith("data: ")
|
|
? `data: ${line.slice(5)}\n\n`
|
|
: `${line}\n\n`;
|
|
|
|
if (parsedPassthroughData) {
|
|
context.pushProviderPayload(parsedPassthroughData);
|
|
if (context.shouldAbortOnClaudeLifecycle(parsedPassthroughData)) {
|
|
context.emitClaudeEmptyStreamErrorAndAbort();
|
|
return true;
|
|
}
|
|
|
|
if (context.isClaudeEventPayload(parsedPassthroughData)) {
|
|
context.updateClaudeEmptyResponseLifecycle(parsedPassthroughData);
|
|
}
|
|
|
|
const parsed = parsedPassthroughData as JsonRecord;
|
|
const parsedType = typeof parsed.type === "string" ? parsed.type : "";
|
|
const isResponses = parsedType.startsWith("response.");
|
|
const isClaude = context.isClaudeEventPayload(parsed);
|
|
|
|
if (isResponses) {
|
|
output = handleResponsesTailPayload(parsed, output, context);
|
|
} else if (!isClaude) {
|
|
handleOpenAiTailPayload(parsed, context);
|
|
}
|
|
|
|
context.pushClientPayload(parsed);
|
|
}
|
|
|
|
output = context.passthroughEventPrefix.prefixData(output, line);
|
|
context.emitConvertedOutput(output);
|
|
return false;
|
|
}
|