diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 10eeab60..3ab6e9bf 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -4,19 +4,25 @@ import { createErrorResult } from "../../utils/error.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { FORMATS } from "../../translator/formats.js"; import { PROVIDERS } from "../../config/providers.js"; -import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js"; +import { + buildRequestDetail, + extractRequestConfig, + saveUsageStats, + formatDoneLine, +} from "./requestDetail.js"; // Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape -const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES; +const isResponsesProvider = (p) => + PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES; 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 ""; + 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 ""; } /** @@ -24,15 +30,15 @@ function textFromResponsesMessageItem(item) { * 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) }; + 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) }; } /** @@ -40,222 +46,388 @@ function pickAssistantMessageForChatCompletion(output) { * Used when provider forces streaming but client wants non-streaming. */ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { - const chunks = []; - let streamError = null; + const chunks = []; + let streamError = null; - for (const line of String(rawSSE || "").split("\n")) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - const payload = trimmed.slice(5).trim(); - if (!payload || payload === "[DONE]") continue; - try { - const chunk = JSON.parse(payload); - if (chunk?.error) streamError = chunk.error; - else chunks.push(chunk); - } catch { /* ignore malformed lines */ } - } + for (const line of String(rawSSE || "").split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + try { + const chunk = JSON.parse(payload); + if (chunk?.error) streamError = chunk.error; + else chunks.push(chunk); + } catch { + /* ignore malformed lines */ + } + } - if (streamError) return { error: streamError }; - if (chunks.length === 0) return null; + if (streamError) return { error: streamError }; + if (chunks.length === 0) return null; - const first = chunks[0]; - const contentParts = []; - const reasoningParts = []; - const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } } - let finishReason = "stop"; - let usage = null; + const first = chunks[0]; + const contentParts = []; + const reasoningParts = []; + const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } } + let finishReason = "stop"; + let usage = null; - 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); - if (choice?.finish_reason) finishReason = choice.finish_reason; - if (chunk?.usage && typeof chunk.usage === "object") usage = chunk.usage; + 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); + if (choice?.finish_reason) finishReason = choice.finish_reason; + if (chunk?.usage && typeof chunk.usage === "object") usage = chunk.usage; - // Accumulate tool_calls from streaming deltas - if (Array.isArray(delta.tool_calls)) { - for (const tc of delta.tool_calls) { - const idx = tc.index ?? 0; - if (!toolCallMap.has(idx)) { - toolCallMap.set(idx, { id: tc.id || "", type: "function", function: { name: "", arguments: "" } }); - } - const existing = toolCallMap.get(idx); - if (tc.id) existing.id = tc.id; - if (tc.function?.name) existing.function.name += tc.function.name; - if (tc.function?.arguments) existing.function.arguments += tc.function.arguments; - } - } - } + // Accumulate tool_calls from streaming deltas + if (Array.isArray(delta.tool_calls)) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (!toolCallMap.has(idx)) { + toolCallMap.set(idx, { + id: tc.id || "", + type: "function", + function: { name: "", arguments: "" }, + }); + } + const existing = toolCallMap.get(idx); + if (tc.id) existing.id = tc.id; + if (tc.function?.name) existing.function.name += tc.function.name; + if (tc.function?.arguments) + existing.function.arguments += tc.function.arguments; + } + } + } - const message = { role: "assistant", content: contentParts.join("") || (toolCallMap.size > 0 ? null : "") }; - if (reasoningParts.length > 0) message.reasoning_content = reasoningParts.join(""); - if (toolCallMap.size > 0) { - message.tool_calls = [...toolCallMap.entries()].sort((a, b) => a[0] - b[0]).map(([, tc]) => tc); - } + const message = { + role: "assistant", + content: contentParts.join("") || (toolCallMap.size > 0 ? null : ""), + }; + if (reasoningParts.length > 0) + message.reasoning_content = reasoningParts.join(""); + if (toolCallMap.size > 0) { + message.tool_calls = [...toolCallMap.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, tc]) => tc); + } - const result = { - id: 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; + const result = { + id: 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; } /** * Handle case: provider forced streaming but client wants JSON. * Supports both Codex/Responses API SSE and standard Chat Completions SSE. */ -export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog, reqTag, log, streamErrorPatterns }) { - const contentType = providerResponse.headers.get("content-type") || ""; - const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider)); - if (!isSSE) return null; // not handled here +export async function handleForcedSSEToJson({ + providerResponse, + sourceFormat, + provider, + model, + body, + stream, + translatedBody, + finalBody, + requestStartTime, + connectionId, + apiKey, + clientRawRequest, + onRequestSuccess, + trackDone, + appendLog, + reqTag, + log, + streamErrorPatterns, +}) { + const contentType = providerResponse.headers.get("content-type") || ""; + const isSSE = + contentType.includes("text/event-stream") || + (contentType === "" && isResponsesProvider(provider)); + if (!isSSE) return null; // not handled here - trackDone(); + trackDone(); - const ctx = { - provider, model, connectionId, - request: extractRequestConfig(body, stream), - providerRequest: finalBody || translatedBody || null - }; + const ctx = { + provider, + model, + connectionId, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + }; - // Codex/Responses API SSE path - const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES; - if (isCodexResponsesApi) { - try { - const jsonResponse = await convertResponsesStreamToJson(providerResponse.body); - if (onRequestSuccess) await onRequestSuccess(); + // Codex/Responses API SSE path + const isCodexResponsesApi = + isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES; + if (isCodexResponsesApi) { + try { + const jsonResponse = await convertResponsesStreamToJson( + providerResponse.body, + ); + if (onRequestSuccess) await onRequestSuccess(); - const usage = jsonResponse.usage || {}; - appendLog({ tokens: usage, status: "200 OK" }); - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); - if (log?.line) log.line(reqTag, "๐", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } })); + const usage = jsonResponse.usage || {}; + appendLog({ tokens: usage, status: "200 OK" }); + saveUsageStats({ + provider, + model, + tokens: usage, + connectionId, + apiKey, + endpoint: clientRawRequest?.endpoint, + silent: true, + }); + if (log?.line) + log.line( + reqTag, + "๐", + formatDoneLine({ + usage, + latency: { total: Date.now() - requestStartTime }, + }), + ); - const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output); - const totalLatency = Date.now() - requestStartTime; + const { msgItem, textContent } = pickAssistantMessageForChatCompletion( + jsonResponse.output, + ); + const totalLatency = Date.now() - requestStartTime; - saveRequestDetail(buildRequestDetail({ - ...ctx, - latency: { ttft: totalLatency, total: totalLatency }, - tokens: { prompt_tokens: usage.input_tokens || 0, completion_tokens: usage.output_tokens || 0 }, - response: { content: textContent, thinking: null, finish_reason: jsonResponse.status || "unknown" }, - status: "success" - }, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {}); + saveRequestDetail( + buildRequestDetail( + { + ...ctx, + latency: { ttft: totalLatency, total: totalLatency }, + tokens: { + prompt_tokens: usage.input_tokens || 0, + completion_tokens: usage.output_tokens || 0, + }, + response: { + content: textContent, + thinking: null, + finish_reason: jsonResponse.status || "unknown", + }, + status: "success", + }, + { endpoint: clientRawRequest?.endpoint || null }, + ), + ).catch(() => {}); - // Client is Responses API โ return as-is - if (sourceFormat === FORMATS.OPENAI_RESPONSES) { - return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; - } + // Client is Responses API โ return as-is + if (sourceFormat === FORMATS.OPENAI_RESPONSES) { + return { + success: true, + response: new Response(JSON.stringify(jsonResponse), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }), + }; + } - // Build client-format response - const inTokens = usage.input_tokens || 0; - const outTokens = usage.output_tokens || 0; - let finalResp; + // Build client-format response + const inTokens = usage.input_tokens || 0; + const outTokens = usage.output_tokens || 0; + let finalResp; - // Extract tool calls from Responses API output (function_call items) - const funcCallItems = (jsonResponse.output || []).filter(item => item.type === "function_call"); - const toolCalls = funcCallItems.map((item, idx) => ({ - id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`, - type: "function", - function: { - name: item.name, - arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {}) - } - })); - const hasToolCalls = toolCalls.length > 0; + // Extract tool calls from Responses API output (function_call items) + const funcCallItems = (jsonResponse.output || []).filter( + (item) => item.type === "function_call", + ); + const toolCalls = funcCallItems.map((item, idx) => ({ + id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`, + type: "function", + function: { + name: item.name, + arguments: + typeof item.arguments === "string" + ? item.arguments + : JSON.stringify(item.arguments || {}), + }, + })); + const hasToolCalls = toolCalls.length > 0; - if (sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI) { - finalResp = { - response: { - candidates: [{ content: { role: "model", parts: [{ text: textContent || "" }] }, finishReason: "STOP", index: 0 }], - usageMetadata: { promptTokenCount: inTokens, candidatesTokenCount: outTokens, totalTokenCount: inTokens + outTokens }, - modelVersion: model, - responseId: jsonResponse.id || `resp_${Date.now()}` - } - }; - } else { - const message = { role: "assistant", content: textContent || (hasToolCalls ? null : "") }; - if (hasToolCalls) message.tool_calls = toolCalls; - const responseDone = jsonResponse.status === "completed" || jsonResponse.status === "done"; - const finishReason = hasToolCalls ? "tool_calls" : (responseDone ? "stop" : (jsonResponse.status || "stop")); - finalResp = { - id: jsonResponse.id || `chatcmpl-${Date.now()}`, - object: "chat.completion", - created: jsonResponse.created_at || Math.floor(Date.now() / 1000), - model: jsonResponse.model || model, - choices: [{ index: 0, message, finish_reason: finishReason }], - usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens } - }; - } + if ( + sourceFormat === FORMATS.ANTIGRAVITY || + sourceFormat === FORMATS.GEMINI || + sourceFormat === FORMATS.GEMINI_CLI + ) { + finalResp = { + response: { + candidates: [ + { + content: { + role: "model", + parts: [{ text: textContent || "" }], + }, + finishReason: "STOP", + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: inTokens, + candidatesTokenCount: outTokens, + totalTokenCount: inTokens + outTokens, + }, + modelVersion: model, + responseId: jsonResponse.id || `resp_${Date.now()}`, + }, + }; + } else { + const message = { + role: "assistant", + content: textContent || (hasToolCalls ? null : ""), + }; + if (hasToolCalls) message.tool_calls = toolCalls; + const responseDone = + jsonResponse.status === "completed" || jsonResponse.status === "done"; + const finishReason = hasToolCalls + ? "tool_calls" + : responseDone + ? "stop" + : jsonResponse.status || "stop"; + finalResp = { + id: jsonResponse.id || `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: jsonResponse.created_at || Math.floor(Date.now() / 1000), + model: jsonResponse.model || model, + choices: [{ index: 0, message, finish_reason: finishReason }], + usage: { + prompt_tokens: inTokens, + completion_tokens: outTokens, + total_tokens: inTokens + outTokens, + }, + }; + } - return { success: true, response: new Response(JSON.stringify(finalResp), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; - } catch (err) { - console.error("[ChatCore] Responses API SSEโJSON failed:", err); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); - } - } + return { + success: true, + response: new Response(JSON.stringify(finalResp), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }), + }; + } catch (err) { + console.error("[ChatCore] Responses API SSEโJSON failed:", err); + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + "Failed to convert streaming response to JSON", + ); + } + } - // Standard Chat Completions SSE path - try { - const sseText = await providerResponse.text(); - const parsed = parseSSEToOpenAIResponse(sseText, model); - if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request"); - if (parsed.error) { - return createErrorResult( - HTTP_STATUS.BAD_GATEWAY, - parsed.error.message || "Upstream SSE stream failed" - ); - } + // Standard Chat Completions SSE path + try { + const sseText = await providerResponse.text(); + const parsed = parseSSEToOpenAIResponse(sseText, model); + if (!parsed) + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + "Invalid SSE response for non-streaming request", + ); + if (parsed.error) { + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + parsed.error.message || "Upstream SSE stream failed", + ); + } - // Config-driven in-stream error detection: the request "succeeded" at the - // HTTP level, but the content signals an upstream failure โ treat it as an - // error so account/combo fallback and FAILED logging kick in. - const matchedPattern = matchStreamErrorPatterns( - streamErrorPatterns?.[provider], - parsed.choices?.[0]?.message?.content || "" - ); - if (matchedPattern) { - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Stream error pattern matched: ${matchedPattern}`); - } + // Config-driven in-stream error detection: the request "succeeded" at the + // HTTP level, but the content signals an upstream failure โ treat it as an + // error so account/combo fallback and FAILED logging kick in. + const matchedPattern = matchStreamErrorPatterns( + streamErrorPatterns?.[provider], + parsed.choices?.[0]?.message?.content || "", + ); + if (matchedPattern) { + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + `Stream error pattern matched: ${matchedPattern}`, + ); + } - if (onRequestSuccess) await onRequestSuccess(); + if (onRequestSuccess) await onRequestSuccess(); - const usage = parsed.usage || {}; - appendLog({ tokens: usage, status: "200 OK" }); - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); - if (log?.line) log.line(reqTag, "๐", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } })); + const usage = parsed.usage || {}; + appendLog({ tokens: usage, status: "200 OK" }); + saveUsageStats({ + provider, + model, + tokens: usage, + connectionId, + apiKey, + endpoint: clientRawRequest?.endpoint, + silent: true, + }); + if (log?.line) + log.line( + reqTag, + "๐", + formatDoneLine({ + usage, + latency: { total: Date.now() - requestStartTime }, + }), + ); - const totalLatency = Date.now() - requestStartTime; - saveRequestDetail(buildRequestDetail({ - ...ctx, - latency: { ttft: totalLatency, total: totalLatency }, - tokens: usage, - response: { - content: parsed.choices?.[0]?.message?.content || null, - thinking: parsed.choices?.[0]?.message?.reasoning_content || null, - finish_reason: parsed.choices?.[0]?.finish_reason || "unknown" - }, - status: "success" - }, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {}); + const totalLatency = Date.now() - requestStartTime; + saveRequestDetail( + buildRequestDetail( + { + ...ctx, + latency: { ttft: totalLatency, total: totalLatency }, + tokens: usage, + response: { + content: parsed.choices?.[0]?.message?.content || null, + thinking: parsed.choices?.[0]?.message?.reasoning_content || null, + finish_reason: parsed.choices?.[0]?.finish_reason || "unknown", + }, + status: "success", + }, + { endpoint: clientRawRequest?.endpoint || null }, + ), + ).catch(() => {}); - // Strip reasoning_content only when content is non-empty. - // When content is empty (e.g. thinking models that used all tokens for reasoning), - // reasoning_content is the only useful output and must be preserved. - // Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc. - if (parsed?.choices) { - for (const choice of parsed.choices) { - if (choice?.message?.reasoning_content && choice.message.content) { - delete choice.message.reasoning_content; - } - } - } + // Strip reasoning_content only when content is non-empty. + // When content is empty (e.g. thinking models that used all tokens for reasoning), + // reasoning_content is the only useful output and must be preserved. + // Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc. + if (parsed?.choices) { + for (const choice of parsed.choices) { + if (choice?.message?.reasoning_content && choice.message.content) { + delete choice.message.reasoning_content; + } + } + } - return { success: true, response: new Response(JSON.stringify(parsed), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; - } catch (err) { - console.error("[ChatCore] Chat Completions SSEโJSON failed:", err); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); - } + return { + success: true, + response: new Response(JSON.stringify(parsed), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }), + }; + } catch (err) { + console.error("[ChatCore] Chat Completions SSEโJSON failed:", err); + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + "Failed to convert streaming response to JSON", + ); + } } diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index ce765bff..15fdfd2f 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -1,11 +1,19 @@ import { FORMATS } from "../../translator/formats.js"; import { needsTranslation } from "../../translator/index.js"; -import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger } from "../../utils/stream.js"; +import { + createSSETransformStreamWithLogger, + createPassthroughStreamWithLogger, +} from "../../utils/stream.js"; import { pipeWithDisconnect } from "../../utils/streamHandler.js"; import { PROVIDERS } from "../../config/providers.js"; import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js"; import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js"; -import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js"; +import { + buildRequestDetail, + extractRequestConfig, + saveUsageStats, + formatDoneLine, +} from "./requestDetail.js"; import { streamStatusForContent } from "../../utils/streamErrorPatterns.js"; import { saveRequestDetail } from "@/lib/usageDb.js"; import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js"; @@ -13,133 +21,316 @@ import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js"; // Codex returns Responses API SSE โ which client format to translate INTO, by request sourceFormat. // Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI. const CODEX_SOURCE_TO_TARGET = { - [FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES, - [FORMATS.CLAUDE]: FORMATS.CLAUDE, - [FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY, - [FORMATS.GEMINI]: FORMATS.ANTIGRAVITY, - [FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY, + [FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES, + [FORMATS.CLAUDE]: FORMATS.CLAUDE, + [FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY, + [FORMATS.GEMINI]: FORMATS.ANTIGRAVITY, + [FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY, }; /** * Determine which SSE transform stream to use based on provider/format. */ -function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) { - const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); - // Responses-API providers (e.g. codex) emit Responses SSE โ translate into client format - const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES; - const needsCodexTranslation = isResponsesProvider && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI; +function buildTransformStream({ + provider, + sourceFormat, + targetFormat, + userAgent, + reqLogger, + toolNameMap, + model, + connectionId, + body, + onStreamComplete, + apiKey, +}) { + const isDroidCLI = + userAgent?.toLowerCase().includes("droid") || + userAgent?.toLowerCase().includes("codex-cli"); + // Responses-API providers (e.g. codex) emit Responses SSE โ translate into client format + const isResponsesProvider = + PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES; + const needsCodexTranslation = + isResponsesProvider && + targetFormat === FORMATS.OPENAI_RESPONSES && + !isDroidCLI; - if (needsCodexTranslation) { - const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; - return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); - } + if (needsCodexTranslation) { + const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; + return createSSETransformStreamWithLogger( + FORMATS.OPENAI_RESPONSES, + codexTarget, + provider, + reqLogger, + toolNameMap, + model, + connectionId, + body, + onStreamComplete, + apiKey, + ); + } - if (needsTranslation(targetFormat, sourceFormat)) { - return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); - } + if (needsTranslation(targetFormat, sourceFormat)) { + return createSSETransformStreamWithLogger( + targetFormat, + sourceFormat, + provider, + reqLogger, + toolNameMap, + model, + connectionId, + body, + onStreamComplete, + apiKey, + ); + } - return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey); + return createPassthroughStreamWithLogger( + provider, + reqLogger, + model, + connectionId, + body, + onStreamComplete, + apiKey, + ); } /** * Handle streaming response โ pipe provider SSE through transform stream to client. */ -export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log }) { - if (onRequestSuccess) { - Promise.resolve() - .then(onRequestSuccess) - .catch(err => { - console.error("[ChatCore] onRequestSuccess failed:", err?.message || err); - }); - } +export async function handleStreamingResponse({ + providerResponse, + provider, + model, + sourceFormat, + targetFormat, + userAgent, + body, + stream, + translatedBody, + finalBody, + requestStartTime, + connectionId, + apiKey, + clientRawRequest, + onRequestSuccess, + reqLogger, + toolNameMap, + streamController, + onStreamComplete, + streamDetailId, + pxpipe, + reqTag, + log, +}) { + if (onRequestSuccess) { + Promise.resolve() + .then(onRequestSuccess) + .catch((err) => { + console.error( + "[ChatCore] onRequestSuccess failed:", + err?.message || err, + ); + }); + } - // When upstream returns HTML/text instead of SSE (e.g. Cloudflare 5xx error - // page), piping it through the SSE transform stream causes Next.js - // "failed to pipe response" and crashes the chat router. Read the body, - // pull a short human-readable message from the
- Treat HTTP-200 streams whose first bytes match one of these
- patterns as failed requests (enables fallback + FAILED logs). One
- pattern per line: plain text (case-insensitive substring) or{" "}
+ Treat HTTP-200 streams whose first bytes match one of these patterns
+ as failed requests (enables fallback + FAILED logs). One pattern per
+ line: plain text (case-insensitive substring) or{" "}
/regex/flags
diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js
index af78b60e..c852d103 100644
--- a/src/lib/db/repos/settingsRepo.js
+++ b/src/lib/db/repos/settingsRepo.js
@@ -2,117 +2,118 @@ import { getAdapter } from "../driver.js";
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
-const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";
+const DEFAULT_HEADROOM_URL =
+ process.env.HEADROOM_URL || "http://localhost:8787";
const DEFAULT_SETTINGS = {
- cloudEnabled: false,
- tunnelEnabled: false,
- tunnelUrl: "",
- tunnelProvider: "cloudflare",
- tailscaleEnabled: false,
- tailscaleUrl: "",
- stickyRoundRobinLimit: 3,
- providerStrategies: {},
- providerTimeouts: {},
- streamErrorPatterns: {},
- defaultTimeoutMs: null,
- quotaVisibility: {},
- comboStrategy: "fallback",
- comboStickyRoundRobinLimit: 1,
- comboStrategies: {},
- requireLogin: true,
- tunnelDashboardAccess: true,
- authMode: "password",
- oidcIssuerUrl: "",
- oidcClientId: "",
- oidcClientSecret: "",
- oidcScopes: "openid profile email",
- oidcLoginLabel: "Sign in with OIDC",
- enableObservability: true,
- observabilityMaxRecords: 1000,
- observabilityBatchSize: 20,
- observabilityFlushIntervalMs: 5000,
- observabilityMaxJsonSize: 5,
- outboundProxyEnabled: false,
- outboundProxyUrl: "",
- outboundNoProxy: "",
- mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
- dnsToolEnabled: {},
- rtkEnabled: true,
- headroomEnabled: false,
- headroomUrl: DEFAULT_HEADROOM_URL,
- headroomCompressUserMessages: false,
- cavemanEnabled: false,
- cavemanLevel: "full",
- ponytailEnabled: false,
- ponytailLevel: "full",
- pxpipeEnabled: false,
- pxpipeAutoInstall: true,
- pxpipeMinChars: 25000,
- pxpipeTimeoutMs: 15000,
+ cloudEnabled: false,
+ tunnelEnabled: false,
+ tunnelUrl: "",
+ tunnelProvider: "cloudflare",
+ tailscaleEnabled: false,
+ tailscaleUrl: "",
+ stickyRoundRobinLimit: 3,
+ providerStrategies: {},
+ providerTimeouts: {},
+ streamErrorPatterns: {},
+ defaultTimeoutMs: null,
+ quotaVisibility: {},
+ comboStrategy: "fallback",
+ comboStickyRoundRobinLimit: 1,
+ comboStrategies: {},
+ requireLogin: true,
+ tunnelDashboardAccess: true,
+ authMode: "password",
+ oidcIssuerUrl: "",
+ oidcClientId: "",
+ oidcClientSecret: "",
+ oidcScopes: "openid profile email",
+ oidcLoginLabel: "Sign in with OIDC",
+ enableObservability: true,
+ observabilityMaxRecords: 1000,
+ observabilityBatchSize: 20,
+ observabilityFlushIntervalMs: 5000,
+ observabilityMaxJsonSize: 5,
+ outboundProxyEnabled: false,
+ outboundProxyUrl: "",
+ outboundNoProxy: "",
+ mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
+ dnsToolEnabled: {},
+ rtkEnabled: true,
+ headroomEnabled: false,
+ headroomUrl: DEFAULT_HEADROOM_URL,
+ headroomCompressUserMessages: false,
+ cavemanEnabled: false,
+ cavemanLevel: "full",
+ ponytailEnabled: false,
+ ponytailLevel: "full",
+ pxpipeEnabled: false,
+ pxpipeAutoInstall: true,
+ pxpipeMinChars: 25000,
+ pxpipeTimeoutMs: 15000,
};
async function readRaw() {
- const db = await getAdapter();
- const row = db.get(`SELECT data FROM settings WHERE id = 1`);
- return row ? parseJson(row.data, {}) : {};
+ const db = await getAdapter();
+ const row = db.get(`SELECT data FROM settings WHERE id = 1`);
+ return row ? parseJson(row.data, {}) : {};
}
// Merge raw settings with defaults; backward-compat for missing keys
function mergeWithDefaults(raw) {
- const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
- for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
- if (merged[key] === undefined) {
- if (
- key === "outboundProxyEnabled" &&
- typeof merged.outboundProxyUrl === "string" &&
- merged.outboundProxyUrl.trim()
- ) {
- merged[key] = true;
- } else {
- merged[key] = defVal;
- }
- }
- }
- return merged;
+ const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
+ for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
+ if (merged[key] === undefined) {
+ if (
+ key === "outboundProxyEnabled" &&
+ typeof merged.outboundProxyUrl === "string" &&
+ merged.outboundProxyUrl.trim()
+ ) {
+ merged[key] = true;
+ } else {
+ merged[key] = defVal;
+ }
+ }
+ }
+ return merged;
}
export async function getSettings() {
- const raw = await readRaw();
- return mergeWithDefaults(raw);
+ const raw = await readRaw();
+ return mergeWithDefaults(raw);
}
// Atomic read-merge-write inside transaction (prevents losing concurrent updates)
export async function updateSettings(updates) {
- const db = await getAdapter();
- let next;
- db.transaction(() => {
- const row = db.get(`SELECT data FROM settings WHERE id = 1`);
- const current = row ? parseJson(row.data, {}) : {};
- next = { ...current, ...updates };
- db.run(
- `INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
- [stringifyJson(next)]
- );
- });
- return mergeWithDefaults(next);
+ const db = await getAdapter();
+ let next;
+ db.transaction(() => {
+ const row = db.get(`SELECT data FROM settings WHERE id = 1`);
+ const current = row ? parseJson(row.data, {}) : {};
+ next = { ...current, ...updates };
+ db.run(
+ `INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
+ [stringifyJson(next)],
+ );
+ });
+ return mergeWithDefaults(next);
}
export async function isCloudEnabled() {
- const settings = await getSettings();
- return settings.cloudEnabled === true;
+ const settings = await getSettings();
+ return settings.cloudEnabled === true;
}
export async function getCloudUrl() {
- const settings = await getSettings();
- return (
- settings.cloudUrl ||
- process.env.CLOUD_URL ||
- process.env.NEXT_PUBLIC_CLOUD_URL ||
- ""
- );
+ const settings = await getSettings();
+ return (
+ settings.cloudUrl ||
+ process.env.CLOUD_URL ||
+ process.env.NEXT_PUBLIC_CLOUD_URL ||
+ ""
+ );
}
export async function exportSettings() {
- return await readRaw();
+ return await readRaw();
}
diff --git a/tests/unit/commandcode-executor.test.js b/tests/unit/commandcode-executor.test.js
index 63cfd489..96729e53 100644
--- a/tests/unit/commandcode-executor.test.js
+++ b/tests/unit/commandcode-executor.test.js
@@ -115,7 +115,10 @@ describe("commandcode executor โ early-error peek", () => {
c.close();
},
});
- const res = await peekForUpstreamError(new Response(body, { status: 200 }), "m");
+ const res = await peekForUpstreamError(
+ new Response(body, { status: 200 }),
+ "m",
+ );
const text = await res.text();
expect(text).toContain("cafรฉ");
expect(text).not.toContain("\uFFFD");
diff --git a/tests/unit/sse-error-patterns.test.js b/tests/unit/sse-error-patterns.test.js
index 59cced66..fe978dbe 100644
--- a/tests/unit/sse-error-patterns.test.js
+++ b/tests/unit/sse-error-patterns.test.js
@@ -3,73 +3,88 @@ import { handleForcedSSEToJson } from "../../open-sse/handlers/chatCore/sseToJso
const encoder = new TextEncoder();
const sseResponse = (chunks) => {
- const body = new ReadableStream({
- start(c) {
- for (const ch of chunks) c.enqueue(encoder.encode(`data: ${JSON.stringify(ch)}\n\n`));
- c.enqueue(encoder.encode("data: [DONE]\n\n"));
- c.close();
- },
- });
- return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
+ const body = new ReadableStream({
+ start(c) {
+ for (const ch of chunks)
+ c.enqueue(encoder.encode(`data: ${JSON.stringify(ch)}\n\n`));
+ c.enqueue(encoder.encode("data: [DONE]\n\n"));
+ c.close();
+ },
+ });
+ return new Response(body, {
+ status: 200,
+ headers: { "content-type": "text/event-stream" },
+ });
};
const mkChunk = (content, finish = null) => ({
- id: "x",
- object: "chat.completion.chunk",
- created: 1,
- model: "m",
- choices: [{ index: 0, delta: content ? { content } : {}, finish_reason: finish }],
+ id: "x",
+ object: "chat.completion.chunk",
+ created: 1,
+ model: "m",
+ choices: [
+ { index: 0, delta: content ? { content } : {}, finish_reason: finish },
+ ],
});
const baseCtx = {
- provider: "fakeprovider",
- model: "m",
- body: { stream: false },
- stream: true,
- translatedBody: null,
- finalBody: null,
- requestStartTime: Date.now(),
- connectionId: "c1",
- apiKey: null,
- clientRawRequest: null,
- onRequestSuccess: null,
- pxpipe: null,
- reqTag: "",
- log: null,
- trackDone: () => {},
- appendLog: () => {},
- reqLogger: null,
- toolNameMap: null,
- sourceFormat: "openai",
+ provider: "fakeprovider",
+ model: "m",
+ body: { stream: false },
+ stream: true,
+ translatedBody: null,
+ finalBody: null,
+ requestStartTime: Date.now(),
+ connectionId: "c1",
+ apiKey: null,
+ clientRawRequest: null,
+ onRequestSuccess: null,
+ pxpipe: null,
+ reqTag: "",
+ log: null,
+ trackDone: () => {},
+ appendLog: () => {},
+ reqLogger: null,
+ toolNameMap: null,
+ sourceFormat: "openai",
};
describe("Layer 1 โ non-streaming stream error patterns", () => {
- it("returns a 502 error result when content matches a configured pattern", async () => {
- const result = await handleForcedSSEToJson({
- ...baseCtx,
- streamErrorPatterns: { fakeprovider: ["Network connection lost"] },
- providerResponse: sseResponse([mkChunk("Network connection lost."), mkChunk(null, "stop")]),
- });
- expect(result.success).toBe(false);
- expect(result.status).toBe(502);
- expect(result.error).toContain("Network connection lost");
- });
+ it("returns a 502 error result when content matches a configured pattern", async () => {
+ const result = await handleForcedSSEToJson({
+ ...baseCtx,
+ streamErrorPatterns: { fakeprovider: ["Network connection lost"] },
+ providerResponse: sseResponse([
+ mkChunk("Network connection lost."),
+ mkChunk(null, "stop"),
+ ]),
+ });
+ expect(result.success).toBe(false);
+ expect(result.status).toBe(502);
+ expect(result.error).toContain("Network connection lost");
+ });
- it("succeeds when content does not match", async () => {
- const result = await handleForcedSSEToJson({
- ...baseCtx,
- streamErrorPatterns: { fakeprovider: ["Network connection lost"] },
- providerResponse: sseResponse([mkChunk("hello world"), mkChunk(null, "stop")]),
- });
- expect(result.success).toBe(true);
- });
+ it("succeeds when content does not match", async () => {
+ const result = await handleForcedSSEToJson({
+ ...baseCtx,
+ streamErrorPatterns: { fakeprovider: ["Network connection lost"] },
+ providerResponse: sseResponse([
+ mkChunk("hello world"),
+ mkChunk(null, "stop"),
+ ]),
+ });
+ expect(result.success).toBe(true);
+ });
- it("ignores patterns for other providers", async () => {
- const result = await handleForcedSSEToJson({
- ...baseCtx,
- streamErrorPatterns: { otherprovider: ["hello"] },
- providerResponse: sseResponse([mkChunk("hello world"), mkChunk(null, "stop")]),
- });
- expect(result.success).toBe(true);
- });
+ it("ignores patterns for other providers", async () => {
+ const result = await handleForcedSSEToJson({
+ ...baseCtx,
+ streamErrorPatterns: { otherprovider: ["hello"] },
+ providerResponse: sseResponse([
+ mkChunk("hello world"),
+ mkChunk(null, "stop"),
+ ]),
+ });
+ expect(result.success).toBe(true);
+ });
});
diff --git a/tests/unit/streamErrorPatterns.test.js b/tests/unit/streamErrorPatterns.test.js
index 4ecde02c..a95000d2 100644
--- a/tests/unit/streamErrorPatterns.test.js
+++ b/tests/unit/streamErrorPatterns.test.js
@@ -1,50 +1,70 @@
import { describe, it, expect } from "vitest";
-import { parsePatterns, matchStreamErrorPatterns, streamStatusForContent } from "../../open-sse/utils/streamErrorPatterns.js";
+import {
+ parsePatterns,
+ matchStreamErrorPatterns,
+ streamStatusForContent,
+} from "../../open-sse/utils/streamErrorPatterns.js";
describe("streamErrorPatterns util", () => {
- it("matches plain text as case-insensitive substring", () => {
- expect(matchStreamErrorPatterns(["Network connection lost"], "network CONNECTION LOST.")).toBe("Network connection lost");
- expect(matchStreamErrorPatterns(["server_error"], "some normal content")).toBeNull();
- });
+ it("matches plain text as case-insensitive substring", () => {
+ expect(
+ matchStreamErrorPatterns(
+ ["Network connection lost"],
+ "network CONNECTION LOST.",
+ ),
+ ).toBe("Network connection lost");
+ expect(
+ matchStreamErrorPatterns(["server_error"], "some normal content"),
+ ).toBeNull();
+ });
- it("matches /regex/flags", () => {
- expect(matchStreamErrorPatterns(["/generation failed.*retry/i"], "GENERATION FAILED. please RETRY")).toBe("/generation failed.*retry/i");
- expect(matchStreamErrorPatterns(["/\\d+ tokens/"], "used 123 tokens")).toBe("/\\d+ tokens/");
- });
+ it("matches /regex/flags", () => {
+ expect(
+ matchStreamErrorPatterns(
+ ["/generation failed.*retry/i"],
+ "GENERATION FAILED. please RETRY",
+ ),
+ ).toBe("/generation failed.*retry/i");
+ expect(matchStreamErrorPatterns(["/\\d+ tokens/"], "used 123 tokens")).toBe(
+ "/\\d+ tokens/",
+ );
+ });
- it("skips invalid regex and empty entries", () => {
- expect(matchStreamErrorPatterns(["/[unclosed/", "", " "], "anything")).toBeNull();
- });
+ it("skips invalid regex and empty entries", () => {
+ expect(
+ matchStreamErrorPatterns(["/[unclosed/", "", " "], "anything"),
+ ).toBeNull();
+ });
- it("returns null for empty patterns or text", () => {
- expect(matchStreamErrorPatterns([], "x")).toBeNull();
- expect(matchStreamErrorPatterns(["x"], "")).toBeNull();
- expect(matchStreamErrorPatterns(null, "x")).toBeNull();
- expect(matchStreamErrorPatterns(undefined, "x")).toBeNull();
- });
+ it("returns null for empty patterns or text", () => {
+ expect(matchStreamErrorPatterns([], "x")).toBeNull();
+ expect(matchStreamErrorPatterns(["x"], "")).toBeNull();
+ expect(matchStreamErrorPatterns(null, "x")).toBeNull();
+ expect(matchStreamErrorPatterns(undefined, "x")).toBeNull();
+ });
- it("regex matching is stateless across calls", () => {
- const pats = ["/error/i"];
- expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i");
- expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i");
- });
+ it("regex matching is stateless across calls", () => {
+ const pats = ["/error/i"];
+ expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i");
+ expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i");
+ });
- it("parsePatterns normalizes entries", () => {
- const parsed = parsePatterns(["Plain", "/re/g", "", "/bad["]);
- expect(parsed.length).toBe(3);
- expect(parsed[0]).toEqual({ text: "plain", raw: "Plain" });
- expect(parsed[1].regex).toBeInstanceOf(RegExp);
- expect(parsed[2]).toEqual({ text: "/bad[", raw: "/bad[" });
- });
+ it("parsePatterns normalizes entries", () => {
+ const parsed = parsePatterns(["Plain", "/re/g", "", "/bad["]);
+ expect(parsed.length).toBe(3);
+ expect(parsed[0]).toEqual({ text: "plain", raw: "Plain" });
+ expect(parsed[1].regex).toBeInstanceOf(RegExp);
+ expect(parsed[2]).toEqual({ text: "/bad[", raw: "/bad[" });
+ });
- it("skips entries that look like regex but fail to compile", () => {
- const parsed = parsePatterns(["/[unclosed/", "/ok/g"]);
- expect(parsed.length).toBe(1);
- expect(parsed[0].raw).toBe("/ok/g");
- });
+ it("skips entries that look like regex but fail to compile", () => {
+ const parsed = parsePatterns(["/[unclosed/", "/ok/g"]);
+ expect(parsed.length).toBe(1);
+ expect(parsed[0].raw).toBe("/ok/g");
+ });
- it("streamStatusForContent maps match to error status", () => {
- expect(streamStatusForContent(["boom"], "a boom happened")).toBe("error");
- expect(streamStatusForContent(["boom"], "all good")).toBe("success");
- });
+ it("streamStatusForContent maps match to error status", () => {
+ expect(streamStatusForContent(["boom"], "a boom happened")).toBe("error");
+ expect(streamStatusForContent(["boom"], "all good")).toBe("success");
+ });
});