mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
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:
@@ -5,6 +5,31 @@ import { FORMATS } from "../../translator/formats.js";
|
|||||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
|
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
|
||||||
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.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.
|
* Parse OpenAI-style SSE text into a single chat completion JSON.
|
||||||
* Used when provider forces streaming but client wants non-streaming.
|
* 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" });
|
appendLog({ tokens: usage, status: "200 OK" });
|
||||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
|
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
|
||||||
|
|
||||||
const msgItem = jsonResponse.output?.find(item => item.type === "message");
|
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
|
||||||
const textContent = msgItem?.content?.find(c => c.type === "output_text")?.text || msgItem?.content?.[0]?.text || null;
|
|
||||||
const totalLatency = Date.now() - requestStartTime;
|
const totalLatency = Date.now() - requestStartTime;
|
||||||
|
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
|
|||||||
Reference in New Issue
Block a user