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 , sanitize it, and - // return a clean JSON error instead. The message is stripped of HTML tags - // and clamped so untrusted upstream text never reaches the client verbatim - // (the UI may render error.message as HTML). - const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase(); - if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) { - const bodyText = await providerResponse.text().catch(() => ''); - const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i); - const sanitizedTitle = (titleMatch?.[1] || '').replace(/<[^>]*>/g, '').replace(/[\r\n]+/g, ' ').trim().slice(0, 160); - const shortMsg = sanitizedTitle - || (bodyText.length < 200 ? bodyText.replace(/<[^>]*>/g, '').trim().slice(0, 160) : `Upstream returned non-SSE response (${upstreamContentType})`); - const status = providerResponse.status || 502; - if (log?.errorLine) log.errorLine(reqTag, "โœ—", `BLOCKED ${status} ยท ${provider}/${model} ยท non-SSE (${upstreamContentType})\n ${shortMsg}`); - else console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`); - streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`)); - return { - success: false, - response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), { - status, - headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, - }), - }; - } + // 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 <title>, sanitize it, and + // return a clean JSON error instead. The message is stripped of HTML tags + // and clamped so untrusted upstream text never reaches the client verbatim + // (the UI may render error.message as HTML). + const upstreamContentType = ( + providerResponse.headers.get("content-type") || "" + ).toLowerCase(); + if ( + upstreamContentType && + !upstreamContentType.includes("text/event-stream") && + !upstreamContentType.includes("application/json") + ) { + const bodyText = await providerResponse.text().catch(() => ""); + const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i); + const sanitizedTitle = (titleMatch?.[1] || "") + .replace(/<[^>]*>/g, "") + .replace(/[\r\n]+/g, " ") + .trim() + .slice(0, 160); + const shortMsg = + sanitizedTitle || + (bodyText.length < 200 + ? bodyText + .replace(/<[^>]*>/g, "") + .trim() + .slice(0, 160) + : `Upstream returned non-SSE response (${upstreamContentType})`); + const status = providerResponse.status || 502; + if (log?.errorLine) + log.errorLine( + reqTag, + "โœ—", + `BLOCKED ${status} ยท ${provider}/${model} ยท non-SSE (${upstreamContentType})\n ${shortMsg}`, + ); + else + console.warn( + `[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`, + ); + streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`)); + return { + success: false, + response: new Response( + JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), + { + status, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }, + ), + }; + } - const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); + const transformStream = buildTransformStream({ + provider, + sourceFormat, + targetFormat, + userAgent, + reqLogger, + toolNameMap, + model, + connectionId, + body, + onStreamComplete, + apiKey, + }); - // Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event - const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES; - const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null; - const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; - const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs); + // Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event + const isResponsesPassthrough = + sourceFormat === FORMATS.OPENAI_RESPONSES && + targetFormat === FORMATS.OPENAI_RESPONSES; + const onAbortTerminal = isResponsesPassthrough + ? buildAbortedResponsesTerminalBytes + : null; + const stallTimeoutMs = + PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; + const transformedBody = pipeWithDisconnect( + providerResponse, + transformStream, + streamController, + onAbortTerminal, + stallTimeoutMs, + ); - saveRequestDetail(buildRequestDetail({ - provider, model, connectionId, - latency: { ttft: 0, total: Date.now() - requestStartTime }, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - request: extractRequestConfig(body, stream), - providerRequest: finalBody || translatedBody || null, - providerResponse: "[Streaming - raw response not captured]", - response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" }, - pxpipe, - status: "success" - }, { id: streamDetailId })).catch(err => { - console.error("[RequestDetail] Failed to save streaming request:", err.message); - }); + saveRequestDetail( + buildRequestDetail( + { + provider, + model, + connectionId, + latency: { ttft: 0, total: Date.now() - requestStartTime }, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + providerResponse: "[Streaming - raw response not captured]", + response: { + content: "[Streaming in progress...]", + thinking: null, + type: "streaming", + }, + pxpipe, + status: "success", + }, + { id: streamDetailId }, + ), + ).catch((err) => { + console.error( + "[RequestDetail] Failed to save streaming request:", + err.message, + ); + }); - return { - success: true, - response: new Response(transformedBody, { headers: SSE_HEADERS }) - }; + return { + success: true, + response: new Response(transformedBody, { headers: SSE_HEADERS }), + }; } /** * Build onStreamComplete callback for streaming usage tracking. */ -export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, pxpipe, reqTag, log, streamErrorPatterns }) { - const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +export function buildOnStreamComplete({ + provider, + model, + connectionId, + apiKey, + requestStartTime, + body, + stream, + finalBody, + translatedBody, + clientRawRequest, + pxpipe, + reqTag, + log, + streamErrorPatterns, +}) { + const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - const onStreamComplete = (contentObj, usage, ttftAt) => { - const latency = { - ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime, - total: Date.now() - requestStartTime - }; - const safeContent = contentObj?.content || "[Empty streaming response]"; - const safeThinking = contentObj?.thinking || null; + const onStreamComplete = (contentObj, usage, ttftAt) => { + const latency = { + ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime, + total: Date.now() - requestStartTime, + }; + const safeContent = contentObj?.content || "[Empty streaming response]"; + const safeThinking = contentObj?.thinking || null; - saveRequestDetail(buildRequestDetail({ - provider, model, connectionId, - latency, - tokens: usage || { prompt_tokens: 0, completion_tokens: 0 }, - request: extractRequestConfig(body, stream), - providerRequest: finalBody || translatedBody || null, - providerResponse: safeContent, - response: { content: safeContent, thinking: safeThinking, type: "streaming" }, - pxpipe, - status: streamStatusForContent(streamErrorPatterns?.[provider], safeContent) - }, { id: streamDetailId })).catch(err => { - console.error("[RequestDetail] Failed to update streaming content:", err.message); - }); + saveRequestDetail( + buildRequestDetail( + { + provider, + model, + connectionId, + latency, + tokens: usage || { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + providerResponse: safeContent, + response: { + content: safeContent, + thinking: safeThinking, + type: "streaming", + }, + pxpipe, + status: streamStatusForContent( + streamErrorPatterns?.[provider], + safeContent, + ), + }, + { id: streamDetailId }, + ), + ).catch((err) => { + console.error( + "[RequestDetail] Failed to update streaming content:", + err.message, + ); + }); - // Persist stream usage to DB (no console line; the "๐Ÿ“Š done" line below is authoritative) - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE", silent: true }); - if (log?.line) log.line(reqTag, "๐Ÿ“Š", formatDoneLine({ usage, latency })); - }; + // Persist stream usage to DB (no console line; the "๐Ÿ“Š done" line below is authoritative) + saveUsageStats({ + provider, + model, + tokens: usage, + connectionId, + apiKey, + endpoint: clientRawRequest?.endpoint, + label: "STREAM USAGE", + silent: true, + }); + if (log?.line) log.line(reqTag, "๐Ÿ“Š", formatDoneLine({ usage, latency })); + }; - return { onStreamComplete, streamDetailId }; + return { onStreamComplete, streamDetailId }; } diff --git a/open-sse/utils/streamErrorPatterns.js b/open-sse/utils/streamErrorPatterns.js index 084d6815..534fbe74 100644 --- a/open-sse/utils/streamErrorPatterns.js +++ b/open-sse/utils/streamErrorPatterns.js @@ -9,39 +9,39 @@ */ export function parsePatterns(patterns) { - if (!Array.isArray(patterns)) return []; - const out = []; - for (const entry of patterns) { - if (typeof entry !== "string" || !entry.trim()) continue; - const raw = entry.trim(); - const m = raw.match(/^\/(.*)\/([a-z]*)$/s); - if (m) { - try { - out.push({ regex: new RegExp(m[1], m[2]), raw }); - } catch { - // invalid regex โ†’ skip; config errors must never break requests - } - continue; - } - out.push({ text: raw.toLowerCase(), raw }); - } - return out; + if (!Array.isArray(patterns)) return []; + const out = []; + for (const entry of patterns) { + if (typeof entry !== "string" || !entry.trim()) continue; + const raw = entry.trim(); + const m = raw.match(/^\/(.*)\/([a-z]*)$/s); + if (m) { + try { + out.push({ regex: new RegExp(m[1], m[2]), raw }); + } catch { + // invalid regex โ†’ skip; config errors must never break requests + } + continue; + } + out.push({ text: raw.toLowerCase(), raw }); + } + return out; } export function matchStreamErrorPatterns(patterns, text) { - if (!Array.isArray(patterns) || patterns.length === 0) return null; - if (typeof text !== "string" || !text) return null; - const lower = text.toLowerCase(); - for (const p of parsePatterns(patterns)) { - if (p.text && lower.includes(p.text)) return p.raw; - if (p.regex) { - p.regex.lastIndex = 0; // stateless even with /g - if (p.regex.test(text)) return p.raw; - } - } - return null; + if (!Array.isArray(patterns) || patterns.length === 0) return null; + if (typeof text !== "string" || !text) return null; + const lower = text.toLowerCase(); + for (const p of parsePatterns(patterns)) { + if (p.text && lower.includes(p.text)) return p.raw; + if (p.regex) { + p.regex.lastIndex = 0; // stateless even with /g + if (p.regex.test(text)) return p.raw; + } + } + return null; } export function streamStatusForContent(patterns, content) { - return matchStreamErrorPatterns(patterns, content) ? "error" : "success"; + return matchStreamErrorPatterns(patterns, content) ? "error" : "success"; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 6a34b260..c32643f1 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -447,9 +447,7 @@ export default function ProviderDetailPage() { ); // Load per-provider stream error patterns (one per line) setStreamErrorPatternsText( - ((settingsData.streamErrorPatterns || {})[providerId] || []).join( - "\n", - ), + ((settingsData.streamErrorPatterns || {})[providerId] || []).join("\n"), ); const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; const apCfg = autoPingSettingsKey @@ -2495,9 +2493,9 @@ export default function ProviderDetailPage() { <div className="mb-3"> <h2 className="text-lg font-semibold">Stream Error Patterns</h2> <p className="mt-1 text-sm text-text-muted"> - 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{" "} <code className="rounded bg-background px-1 py-0.5 font-mono text-xs"> /regex/flags </code> 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"); + }); });