mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
- Remove forced stream=true from responsesHandler - Add stream-to-JSON converter for non-streaming clients (Codex) - Accept string input in Responses API (normalize to array) - Codex SSE header fallback for missing Content-Type - Refactor: extract shared normalizeResponsesInput() Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
104 lines
3.0 KiB
JavaScript
104 lines
3.0 KiB
JavaScript
/**
|
|
* Stream-to-JSON Converter
|
|
* Converts Responses API SSE stream to single JSON response
|
|
* Used when client requests non-streaming but provider forces streaming (e.g., Codex)
|
|
*/
|
|
|
|
/**
|
|
* Convert Responses API SSE stream to single JSON response
|
|
* @param {ReadableStream} stream - SSE stream from provider
|
|
* @returns {Promise<Object>} Final JSON response in Responses API format
|
|
*/
|
|
export async function convertResponsesStreamToJson(stream) {
|
|
const reader = stream.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
let buffer = "";
|
|
let responseId = "";
|
|
let output = [];
|
|
let created = Math.floor(Date.now() / 1000);
|
|
let status = "in_progress";
|
|
let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
|
|
|
|
// Map of output_index -> item (for ordered output array)
|
|
const items = new Map();
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
// Split by double newline (SSE event separator)
|
|
const messages = buffer.split("\n\n");
|
|
buffer = messages.pop() || ""; // Keep incomplete message in buffer
|
|
|
|
for (const msg of messages) {
|
|
if (!msg.trim()) continue;
|
|
|
|
// Parse SSE event
|
|
const eventMatch = msg.match(/^event:\s*(.+)$/m);
|
|
const dataMatch = msg.match(/^data:\s*(.+)$/m);
|
|
|
|
if (!eventMatch || !dataMatch) continue;
|
|
|
|
const eventType = eventMatch[1].trim();
|
|
const dataStr = dataMatch[1].trim();
|
|
|
|
if (dataStr === "[DONE]") continue;
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(dataStr);
|
|
} catch {
|
|
// Skip malformed JSON
|
|
continue;
|
|
}
|
|
|
|
// Handle different event types
|
|
if (eventType === "response.created") {
|
|
responseId = parsed.response?.id || responseId;
|
|
created = parsed.response?.created_at || created;
|
|
}
|
|
else if (eventType === "response.output_item.done") {
|
|
const idx = parsed.output_index ?? 0;
|
|
items.set(idx, parsed.item);
|
|
}
|
|
else if (eventType === "response.completed") {
|
|
status = "completed";
|
|
if (parsed.response?.usage) {
|
|
usage.input_tokens = parsed.response.usage.input_tokens || 0;
|
|
usage.output_tokens = parsed.response.usage.output_tokens || 0;
|
|
usage.total_tokens = parsed.response.usage.total_tokens || 0;
|
|
}
|
|
}
|
|
else if (eventType === "response.failed") {
|
|
status = "failed";
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
|
|
// Build output array from accumulated items (ordered by index)
|
|
const maxIndex = items.size > 0 ? Math.max(...items.keys()) : -1;
|
|
for (let i = 0; i <= maxIndex; i++) {
|
|
output.push(items.get(i) || {
|
|
type: "message",
|
|
content: [],
|
|
role: "assistant"
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: responseId || `resp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
object: "response",
|
|
created_at: created,
|
|
status: status || "completed",
|
|
output,
|
|
usage
|
|
};
|
|
}
|