fix(chat): pick last non-empty message for Codex Responses SSE

Root cause: Codex/OpenAI Responses streams multiple alternating reasoning and
message output items. The first message block often has empty output_text; the
visible answer lives in a later message. Previous code used output.find() which
always picked the first (empty) message block.

Fix: walk message items from end and use the last message whose extracted text
is non-empty; fall back to final message if all are empty.

Note: Removed debug logging code from original PR #383 to keep implementation clean.

Co-authored-by: lokinh <locnh@uniultra.xyz>
Made-with: Cursor
This commit is contained in:
decolua
2026-03-23 09:29:31 +07:00
parent 4774150ca7
commit 3d4dbdc0e7

View File

@@ -5,6 +5,31 @@ import { FORMATS } from "../../translator/formats.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
function textFromResponsesMessageItem(item) {
if (!item?.content || !Array.isArray(item.content)) return "";
const byType = item.content.find((c) => c.type === "output_text");
if (typeof byType?.text === "string") return byType.text;
const anyText = item.content.find((c) => typeof c.text === "string");
if (typeof anyText?.text === "string") return anyText.text;
return "";
}
/**
* Codex / Responses API may emit many alternating reasoning + message items.
* Early message blocks often have empty output_text; the user-visible answer is usually in the last non-empty message.
*/
function pickAssistantMessageForChatCompletion(output) {
if (!Array.isArray(output)) return { msgItem: null, textContent: null };
const messages = output.filter((item) => item?.type === "message");
if (messages.length === 0) return { msgItem: null, textContent: null };
for (let i = messages.length - 1; i >= 0; i--) {
const text = textFromResponsesMessageItem(messages[i]);
if (text.length > 0) return { msgItem: messages[i], textContent: text };
}
const last = messages[messages.length - 1];
return { msgItem: last, textContent: textFromResponsesMessageItem(last) };
}
/**
* Parse OpenAI-style SSE text into a single chat completion JSON.
* Used when provider forces streaming but client wants non-streaming.
@@ -79,8 +104,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
const msgItem = jsonResponse.output?.find(item => item.type === "message");
const textContent = msgItem?.content?.find(c => c.type === "output_text")?.text || msgItem?.content?.[0]?.text || null;
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({