chore: normalize formatting (2-space → tabs) in stream-error-patterns files

Re-tab only — no logic changes. Follows the repo's tab-based formatting
for these files, matching the CommandCode executor/translator style.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-08-05 09:03:41 +07:00
parent e2f8323ab1
commit 2a37a4085e
8 changed files with 929 additions and 529 deletions

View File

@@ -4,19 +4,25 @@ import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js"; import { FORMATS } from "../../translator/formats.js";
import { PROVIDERS } from "../../config/providers.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 // 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"; import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
function textFromResponsesMessageItem(item) { function textFromResponsesMessageItem(item) {
if (!item?.content || !Array.isArray(item.content)) return ""; if (!item?.content || !Array.isArray(item.content)) return "";
const byType = item.content.find((c) => c.type === "output_text"); const byType = item.content.find((c) => c.type === "output_text");
if (typeof byType?.text === "string") return byType.text; if (typeof byType?.text === "string") return byType.text;
const anyText = item.content.find((c) => typeof c.text === "string"); const anyText = item.content.find((c) => typeof c.text === "string");
if (typeof anyText?.text === "string") return anyText.text; if (typeof anyText?.text === "string") return anyText.text;
return ""; 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. * Early message blocks often have empty output_text; the user-visible answer is usually in the last non-empty message.
*/ */
function pickAssistantMessageForChatCompletion(output) { function pickAssistantMessageForChatCompletion(output) {
if (!Array.isArray(output)) return { msgItem: null, textContent: null }; if (!Array.isArray(output)) return { msgItem: null, textContent: null };
const messages = output.filter((item) => item?.type === "message"); const messages = output.filter((item) => item?.type === "message");
if (messages.length === 0) return { msgItem: null, textContent: null }; if (messages.length === 0) return { msgItem: null, textContent: null };
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const text = textFromResponsesMessageItem(messages[i]); const text = textFromResponsesMessageItem(messages[i]);
if (text.length > 0) return { msgItem: messages[i], textContent: text }; if (text.length > 0) return { msgItem: messages[i], textContent: text };
} }
const last = messages[messages.length - 1]; const last = messages[messages.length - 1];
return { msgItem: last, textContent: textFromResponsesMessageItem(last) }; return { msgItem: last, textContent: textFromResponsesMessageItem(last) };
} }
/** /**
@@ -40,222 +46,388 @@ function pickAssistantMessageForChatCompletion(output) {
* Used when provider forces streaming but client wants non-streaming. * Used when provider forces streaming but client wants non-streaming.
*/ */
export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
const chunks = []; const chunks = [];
let streamError = null; let streamError = null;
for (const line of String(rawSSE || "").split("\n")) { for (const line of String(rawSSE || "").split("\n")) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue; if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim(); const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue; if (!payload || payload === "[DONE]") continue;
try { try {
const chunk = JSON.parse(payload); const chunk = JSON.parse(payload);
if (chunk?.error) streamError = chunk.error; if (chunk?.error) streamError = chunk.error;
else chunks.push(chunk); else chunks.push(chunk);
} catch { /* ignore malformed lines */ } } catch {
} /* ignore malformed lines */
}
}
if (streamError) return { error: streamError }; if (streamError) return { error: streamError };
if (chunks.length === 0) return null; if (chunks.length === 0) return null;
const first = chunks[0]; const first = chunks[0];
const contentParts = []; const contentParts = [];
const reasoningParts = []; const reasoningParts = [];
const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } } const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } }
let finishReason = "stop"; let finishReason = "stop";
let usage = null; let usage = null;
for (const chunk of chunks) { for (const chunk of chunks) {
const choice = chunk?.choices?.[0]; const choice = chunk?.choices?.[0];
const delta = choice?.delta || {}; const delta = choice?.delta || {};
if (typeof delta.content === "string" && delta.content.length > 0) contentParts.push(delta.content); if (typeof delta.content === "string" && delta.content.length > 0)
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) reasoningParts.push(delta.reasoning_content); contentParts.push(delta.content);
if (choice?.finish_reason) finishReason = choice.finish_reason; if (
if (chunk?.usage && typeof chunk.usage === "object") usage = chunk.usage; 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 // Accumulate tool_calls from streaming deltas
if (Array.isArray(delta.tool_calls)) { if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) { for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0; const idx = tc.index ?? 0;
if (!toolCallMap.has(idx)) { if (!toolCallMap.has(idx)) {
toolCallMap.set(idx, { id: tc.id || "", type: "function", function: { name: "", arguments: "" } }); toolCallMap.set(idx, {
} id: tc.id || "",
const existing = toolCallMap.get(idx); type: "function",
if (tc.id) existing.id = tc.id; function: { name: "", arguments: "" },
if (tc.function?.name) existing.function.name += tc.function.name; });
if (tc.function?.arguments) existing.function.arguments += tc.function.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 : "") }; const message = {
if (reasoningParts.length > 0) message.reasoning_content = reasoningParts.join(""); role: "assistant",
if (toolCallMap.size > 0) { content: contentParts.join("") || (toolCallMap.size > 0 ? null : ""),
message.tool_calls = [...toolCallMap.entries()].sort((a, b) => a[0] - b[0]).map(([, tc]) => tc); };
} 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 = { const result = {
id: first.id || `chatcmpl-${Date.now()}`, id: first.id || `chatcmpl-${Date.now()}`,
object: "chat.completion", object: "chat.completion",
created: first.created || Math.floor(Date.now() / 1000), created: first.created || Math.floor(Date.now() / 1000),
model: first.model || fallbackModel || "unknown", model: first.model || fallbackModel || "unknown",
choices: [{ index: 0, message, finish_reason: finishReason }] choices: [{ index: 0, message, finish_reason: finishReason }],
}; };
if (usage) result.usage = usage; if (usage) result.usage = usage;
return result; return result;
} }
/** /**
* Handle case: provider forced streaming but client wants JSON. * Handle case: provider forced streaming but client wants JSON.
* Supports both Codex/Responses API SSE and standard Chat Completions SSE. * 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 }) { export async function handleForcedSSEToJson({
const contentType = providerResponse.headers.get("content-type") || ""; providerResponse,
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider)); sourceFormat,
if (!isSSE) return null; // not handled here 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 = { const ctx = {
provider, model, connectionId, provider,
request: extractRequestConfig(body, stream), model,
providerRequest: finalBody || translatedBody || null connectionId,
}; request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
};
// Codex/Responses API SSE path // Codex/Responses API SSE path
const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES; const isCodexResponsesApi =
if (isCodexResponsesApi) { isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES;
try { if (isCodexResponsesApi) {
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body); try {
if (onRequestSuccess) await onRequestSuccess(); const jsonResponse = await convertResponsesStreamToJson(
providerResponse.body,
);
if (onRequestSuccess) await onRequestSuccess();
const usage = jsonResponse.usage || {}; const usage = jsonResponse.usage || {};
appendLog({ tokens: usage, status: "200 OK" }); appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); saveUsageStats({
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } })); 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 { msgItem, textContent } = pickAssistantMessageForChatCompletion(
const totalLatency = Date.now() - requestStartTime; jsonResponse.output,
);
const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({ saveRequestDetail(
...ctx, buildRequestDetail(
latency: { ttft: totalLatency, total: totalLatency }, {
tokens: { prompt_tokens: usage.input_tokens || 0, completion_tokens: usage.output_tokens || 0 }, ...ctx,
response: { content: textContent, thinking: null, finish_reason: jsonResponse.status || "unknown" }, latency: { ttft: totalLatency, total: totalLatency },
status: "success" tokens: {
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {}); 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 // Client is Responses API → return as-is
if (sourceFormat === FORMATS.OPENAI_RESPONSES) { if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; return {
} success: true,
response: new Response(JSON.stringify(jsonResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}
// Build client-format response // Build client-format response
const inTokens = usage.input_tokens || 0; const inTokens = usage.input_tokens || 0;
const outTokens = usage.output_tokens || 0; const outTokens = usage.output_tokens || 0;
let finalResp; let finalResp;
// Extract tool calls from Responses API output (function_call items) // Extract tool calls from Responses API output (function_call items)
const funcCallItems = (jsonResponse.output || []).filter(item => item.type === "function_call"); const funcCallItems = (jsonResponse.output || []).filter(
const toolCalls = funcCallItems.map((item, idx) => ({ (item) => item.type === "function_call",
id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`, );
type: "function", const toolCalls = funcCallItems.map((item, idx) => ({
function: { id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`,
name: item.name, type: "function",
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {}) function: {
} name: item.name,
})); arguments:
const hasToolCalls = toolCalls.length > 0; 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) { if (
finalResp = { sourceFormat === FORMATS.ANTIGRAVITY ||
response: { sourceFormat === FORMATS.GEMINI ||
candidates: [{ content: { role: "model", parts: [{ text: textContent || "" }] }, finishReason: "STOP", index: 0 }], sourceFormat === FORMATS.GEMINI_CLI
usageMetadata: { promptTokenCount: inTokens, candidatesTokenCount: outTokens, totalTokenCount: inTokens + outTokens }, ) {
modelVersion: model, finalResp = {
responseId: jsonResponse.id || `resp_${Date.now()}` response: {
} candidates: [
}; {
} else { content: {
const message = { role: "assistant", content: textContent || (hasToolCalls ? null : "") }; role: "model",
if (hasToolCalls) message.tool_calls = toolCalls; parts: [{ text: textContent || "" }],
const responseDone = jsonResponse.status === "completed" || jsonResponse.status === "done"; },
const finishReason = hasToolCalls ? "tool_calls" : (responseDone ? "stop" : (jsonResponse.status || "stop")); finishReason: "STOP",
finalResp = { index: 0,
id: jsonResponse.id || `chatcmpl-${Date.now()}`, },
object: "chat.completion", ],
created: jsonResponse.created_at || Math.floor(Date.now() / 1000), usageMetadata: {
model: jsonResponse.model || model, promptTokenCount: inTokens,
choices: [{ index: 0, message, finish_reason: finishReason }], candidatesTokenCount: outTokens,
usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + 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": "*" } }) }; return {
} catch (err) { success: true,
console.error("[ChatCore] Responses API SSE→JSON failed:", err); response: new Response(JSON.stringify(finalResp), {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); 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 // Standard Chat Completions SSE path
try { try {
const sseText = await providerResponse.text(); const sseText = await providerResponse.text();
const parsed = parseSSEToOpenAIResponse(sseText, model); const parsed = parseSSEToOpenAIResponse(sseText, model);
if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request"); if (!parsed)
if (parsed.error) { return createErrorResult(
return createErrorResult( HTTP_STATUS.BAD_GATEWAY,
HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request",
parsed.error.message || "Upstream SSE stream failed" );
); 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 // Config-driven in-stream error detection: the request "succeeded" at the
// HTTP level, but the content signals an upstream failure — treat it as an // HTTP level, but the content signals an upstream failure — treat it as an
// error so account/combo fallback and FAILED logging kick in. // error so account/combo fallback and FAILED logging kick in.
const matchedPattern = matchStreamErrorPatterns( const matchedPattern = matchStreamErrorPatterns(
streamErrorPatterns?.[provider], streamErrorPatterns?.[provider],
parsed.choices?.[0]?.message?.content || "" parsed.choices?.[0]?.message?.content || "",
); );
if (matchedPattern) { if (matchedPattern) {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Stream error pattern matched: ${matchedPattern}`); return createErrorResult(
} HTTP_STATUS.BAD_GATEWAY,
`Stream error pattern matched: ${matchedPattern}`,
);
}
if (onRequestSuccess) await onRequestSuccess(); if (onRequestSuccess) await onRequestSuccess();
const usage = parsed.usage || {}; const usage = parsed.usage || {};
appendLog({ tokens: usage, status: "200 OK" }); appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); saveUsageStats({
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } })); 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; const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({ saveRequestDetail(
...ctx, buildRequestDetail(
latency: { ttft: totalLatency, total: totalLatency }, {
tokens: usage, ...ctx,
response: { latency: { ttft: totalLatency, total: totalLatency },
content: parsed.choices?.[0]?.message?.content || null, tokens: usage,
thinking: parsed.choices?.[0]?.message?.reasoning_content || null, response: {
finish_reason: parsed.choices?.[0]?.finish_reason || "unknown" content: parsed.choices?.[0]?.message?.content || null,
}, thinking: parsed.choices?.[0]?.message?.reasoning_content || null,
status: "success" finish_reason: parsed.choices?.[0]?.finish_reason || "unknown",
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {}); },
status: "success",
},
{ endpoint: clientRawRequest?.endpoint || null },
),
).catch(() => {});
// Strip reasoning_content only when content is non-empty. // Strip reasoning_content only when content is non-empty.
// When content is empty (e.g. thinking models that used all tokens for reasoning), // 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. // reasoning_content is the only useful output and must be preserved.
// Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc. // Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc.
if (parsed?.choices) { if (parsed?.choices) {
for (const choice of parsed.choices) { for (const choice of parsed.choices) {
if (choice?.message?.reasoning_content && choice.message.content) { if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content; delete choice.message.reasoning_content;
} }
} }
} }
return { success: true, response: new Response(JSON.stringify(parsed), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; return {
} catch (err) { success: true,
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err); response: new Response(JSON.stringify(parsed), {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); 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",
);
}
} }

View File

@@ -1,11 +1,19 @@
import { FORMATS } from "../../translator/formats.js"; import { FORMATS } from "../../translator/formats.js";
import { needsTranslation } from "../../translator/index.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 { pipeWithDisconnect } from "../../utils/streamHandler.js";
import { PROVIDERS } from "../../config/providers.js"; import { PROVIDERS } from "../../config/providers.js";
import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js"; import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.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 { streamStatusForContent } from "../../utils/streamErrorPatterns.js";
import { saveRequestDetail } from "@/lib/usageDb.js"; import { saveRequestDetail } from "@/lib/usageDb.js";
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.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. // 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. // Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI.
const CODEX_SOURCE_TO_TARGET = { const CODEX_SOURCE_TO_TARGET = {
[FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES, [FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES,
[FORMATS.CLAUDE]: FORMATS.CLAUDE, [FORMATS.CLAUDE]: FORMATS.CLAUDE,
[FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY, [FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI]: FORMATS.ANTIGRAVITY, [FORMATS.GEMINI]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY, [FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY,
}; };
/** /**
* Determine which SSE transform stream to use based on provider/format. * Determine which SSE transform stream to use based on provider/format.
*/ */
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) { function buildTransformStream({
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); provider,
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format sourceFormat,
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES; targetFormat,
const needsCodexTranslation = isResponsesProvider && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI; 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) { if (needsCodexTranslation) {
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); return createSSETransformStreamWithLogger(
} FORMATS.OPENAI_RESPONSES,
codexTarget,
provider,
reqLogger,
toolNameMap,
model,
connectionId,
body,
onStreamComplete,
apiKey,
);
}
if (needsTranslation(targetFormat, sourceFormat)) { if (needsTranslation(targetFormat, sourceFormat)) {
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); 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. * 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 }) { export async function handleStreamingResponse({
if (onRequestSuccess) { providerResponse,
Promise.resolve() provider,
.then(onRequestSuccess) model,
.catch(err => { sourceFormat,
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err); 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 // When upstream returns HTML/text instead of SSE (e.g. Cloudflare 5xx error
// page), piping it through the SSE transform stream causes Next.js // page), piping it through the SSE transform stream causes Next.js
// "failed to pipe response" and crashes the chat router. Read the body, // "failed to pipe response" and crashes the chat router. Read the body,
// pull a short human-readable message from the <title>, sanitize it, and // 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 // return a clean JSON error instead. The message is stripped of HTML tags
// and clamped so untrusted upstream text never reaches the client verbatim // and clamped so untrusted upstream text never reaches the client verbatim
// (the UI may render error.message as HTML). // (the UI may render error.message as HTML).
const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase(); const upstreamContentType = (
if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) { providerResponse.headers.get("content-type") || ""
const bodyText = await providerResponse.text().catch(() => ''); ).toLowerCase();
const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i); if (
const sanitizedTitle = (titleMatch?.[1] || '').replace(/<[^>]*>/g, '').replace(/[\r\n]+/g, ' ').trim().slice(0, 160); upstreamContentType &&
const shortMsg = sanitizedTitle !upstreamContentType.includes("text/event-stream") &&
|| (bodyText.length < 200 ? bodyText.replace(/<[^>]*>/g, '').trim().slice(0, 160) : `Upstream returned non-SSE response (${upstreamContentType})`); !upstreamContentType.includes("application/json")
const status = providerResponse.status || 502; ) {
if (log?.errorLine) log.errorLine(reqTag, "✗", `BLOCKED ${status} · ${provider}/${model} · non-SSE (${upstreamContentType})\n ${shortMsg}`); const bodyText = await providerResponse.text().catch(() => "");
else console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`); const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i);
streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`)); const sanitizedTitle = (titleMatch?.[1] || "")
return { .replace(/<[^>]*>/g, "")
success: false, .replace(/[\r\n]+/g, " ")
response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), { .trim()
status, .slice(0, 160);
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, 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 // 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 isResponsesPassthrough =
const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null; sourceFormat === FORMATS.OPENAI_RESPONSES &&
const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; targetFormat === FORMATS.OPENAI_RESPONSES;
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs); const onAbortTerminal = isResponsesPassthrough
? buildAbortedResponsesTerminalBytes
: null;
const stallTimeoutMs =
PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS;
const transformedBody = pipeWithDisconnect(
providerResponse,
transformStream,
streamController,
onAbortTerminal,
stallTimeoutMs,
);
saveRequestDetail(buildRequestDetail({ saveRequestDetail(
provider, model, connectionId, buildRequestDetail(
latency: { ttft: 0, total: Date.now() - requestStartTime }, {
tokens: { prompt_tokens: 0, completion_tokens: 0 }, provider,
request: extractRequestConfig(body, stream), model,
providerRequest: finalBody || translatedBody || null, connectionId,
providerResponse: "[Streaming - raw response not captured]", latency: { ttft: 0, total: Date.now() - requestStartTime },
response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" }, tokens: { prompt_tokens: 0, completion_tokens: 0 },
pxpipe, request: extractRequestConfig(body, stream),
status: "success" providerRequest: finalBody || translatedBody || null,
}, { id: streamDetailId })).catch(err => { providerResponse: "[Streaming - raw response not captured]",
console.error("[RequestDetail] Failed to save streaming request:", err.message); 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 { return {
success: true, success: true,
response: new Response(transformedBody, { headers: SSE_HEADERS }) response: new Response(transformedBody, { headers: SSE_HEADERS }),
}; };
} }
/** /**
* Build onStreamComplete callback for streaming usage tracking. * Build onStreamComplete callback for streaming usage tracking.
*/ */
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, pxpipe, reqTag, log, streamErrorPatterns }) { export function buildOnStreamComplete({
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; 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 onStreamComplete = (contentObj, usage, ttftAt) => {
const latency = { const latency = {
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime, ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
total: Date.now() - requestStartTime total: Date.now() - requestStartTime,
}; };
const safeContent = contentObj?.content || "[Empty streaming response]"; const safeContent = contentObj?.content || "[Empty streaming response]";
const safeThinking = contentObj?.thinking || null; const safeThinking = contentObj?.thinking || null;
saveRequestDetail(buildRequestDetail({ saveRequestDetail(
provider, model, connectionId, buildRequestDetail(
latency, {
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 }, provider,
request: extractRequestConfig(body, stream), model,
providerRequest: finalBody || translatedBody || null, connectionId,
providerResponse: safeContent, latency,
response: { content: safeContent, thinking: safeThinking, type: "streaming" }, tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
pxpipe, request: extractRequestConfig(body, stream),
status: streamStatusForContent(streamErrorPatterns?.[provider], safeContent) providerRequest: finalBody || translatedBody || null,
}, { id: streamDetailId })).catch(err => { providerResponse: safeContent,
console.error("[RequestDetail] Failed to update streaming content:", err.message); 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) // 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 }); saveUsageStats({
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency })); 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 };
} }

View File

@@ -9,39 +9,39 @@
*/ */
export function parsePatterns(patterns) { export function parsePatterns(patterns) {
if (!Array.isArray(patterns)) return []; if (!Array.isArray(patterns)) return [];
const out = []; const out = [];
for (const entry of patterns) { for (const entry of patterns) {
if (typeof entry !== "string" || !entry.trim()) continue; if (typeof entry !== "string" || !entry.trim()) continue;
const raw = entry.trim(); const raw = entry.trim();
const m = raw.match(/^\/(.*)\/([a-z]*)$/s); const m = raw.match(/^\/(.*)\/([a-z]*)$/s);
if (m) { if (m) {
try { try {
out.push({ regex: new RegExp(m[1], m[2]), raw }); out.push({ regex: new RegExp(m[1], m[2]), raw });
} catch { } catch {
// invalid regex → skip; config errors must never break requests // invalid regex → skip; config errors must never break requests
} }
continue; continue;
} }
out.push({ text: raw.toLowerCase(), raw }); out.push({ text: raw.toLowerCase(), raw });
} }
return out; return out;
} }
export function matchStreamErrorPatterns(patterns, text) { export function matchStreamErrorPatterns(patterns, text) {
if (!Array.isArray(patterns) || patterns.length === 0) return null; if (!Array.isArray(patterns) || patterns.length === 0) return null;
if (typeof text !== "string" || !text) return null; if (typeof text !== "string" || !text) return null;
const lower = text.toLowerCase(); const lower = text.toLowerCase();
for (const p of parsePatterns(patterns)) { for (const p of parsePatterns(patterns)) {
if (p.text && lower.includes(p.text)) return p.raw; if (p.text && lower.includes(p.text)) return p.raw;
if (p.regex) { if (p.regex) {
p.regex.lastIndex = 0; // stateless even with /g p.regex.lastIndex = 0; // stateless even with /g
if (p.regex.test(text)) return p.raw; if (p.regex.test(text)) return p.raw;
} }
} }
return null; return null;
} }
export function streamStatusForContent(patterns, content) { export function streamStatusForContent(patterns, content) {
return matchStreamErrorPatterns(patterns, content) ? "error" : "success"; return matchStreamErrorPatterns(patterns, content) ? "error" : "success";
} }

View File

@@ -447,9 +447,7 @@ export default function ProviderDetailPage() {
); );
// Load per-provider stream error patterns (one per line) // Load per-provider stream error patterns (one per line)
setStreamErrorPatternsText( setStreamErrorPatternsText(
((settingsData.streamErrorPatterns || {})[providerId] || []).join( ((settingsData.streamErrorPatterns || {})[providerId] || []).join("\n"),
"\n",
),
); );
const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId];
const apCfg = autoPingSettingsKey const apCfg = autoPingSettingsKey
@@ -2495,9 +2493,9 @@ export default function ProviderDetailPage() {
<div className="mb-3"> <div className="mb-3">
<h2 className="text-lg font-semibold">Stream Error Patterns</h2> <h2 className="text-lg font-semibold">Stream Error Patterns</h2>
<p className="mt-1 text-sm text-text-muted"> <p className="mt-1 text-sm text-text-muted">
Treat HTTP-200 streams whose first bytes match one of these Treat HTTP-200 streams whose first bytes match one of these patterns
patterns as failed requests (enables fallback + FAILED logs). One as failed requests (enables fallback + FAILED logs). One pattern per
pattern per line: plain text (case-insensitive substring) or{" "} line: plain text (case-insensitive substring) or{" "}
<code className="rounded bg-background px-1 py-0.5 font-mono text-xs"> <code className="rounded bg-background px-1 py-0.5 font-mono text-xs">
/regex/flags /regex/flags
</code> </code>

View File

@@ -2,117 +2,118 @@ import { getAdapter } from "../driver.js";
import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; 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 = { const DEFAULT_SETTINGS = {
cloudEnabled: false, cloudEnabled: false,
tunnelEnabled: false, tunnelEnabled: false,
tunnelUrl: "", tunnelUrl: "",
tunnelProvider: "cloudflare", tunnelProvider: "cloudflare",
tailscaleEnabled: false, tailscaleEnabled: false,
tailscaleUrl: "", tailscaleUrl: "",
stickyRoundRobinLimit: 3, stickyRoundRobinLimit: 3,
providerStrategies: {}, providerStrategies: {},
providerTimeouts: {}, providerTimeouts: {},
streamErrorPatterns: {}, streamErrorPatterns: {},
defaultTimeoutMs: null, defaultTimeoutMs: null,
quotaVisibility: {}, quotaVisibility: {},
comboStrategy: "fallback", comboStrategy: "fallback",
comboStickyRoundRobinLimit: 1, comboStickyRoundRobinLimit: 1,
comboStrategies: {}, comboStrategies: {},
requireLogin: true, requireLogin: true,
tunnelDashboardAccess: true, tunnelDashboardAccess: true,
authMode: "password", authMode: "password",
oidcIssuerUrl: "", oidcIssuerUrl: "",
oidcClientId: "", oidcClientId: "",
oidcClientSecret: "", oidcClientSecret: "",
oidcScopes: "openid profile email", oidcScopes: "openid profile email",
oidcLoginLabel: "Sign in with OIDC", oidcLoginLabel: "Sign in with OIDC",
enableObservability: true, enableObservability: true,
observabilityMaxRecords: 1000, observabilityMaxRecords: 1000,
observabilityBatchSize: 20, observabilityBatchSize: 20,
observabilityFlushIntervalMs: 5000, observabilityFlushIntervalMs: 5000,
observabilityMaxJsonSize: 5, observabilityMaxJsonSize: 5,
outboundProxyEnabled: false, outboundProxyEnabled: false,
outboundProxyUrl: "", outboundProxyUrl: "",
outboundNoProxy: "", outboundNoProxy: "",
mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE, mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
dnsToolEnabled: {}, dnsToolEnabled: {},
rtkEnabled: true, rtkEnabled: true,
headroomEnabled: false, headroomEnabled: false,
headroomUrl: DEFAULT_HEADROOM_URL, headroomUrl: DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: false, headroomCompressUserMessages: false,
cavemanEnabled: false, cavemanEnabled: false,
cavemanLevel: "full", cavemanLevel: "full",
ponytailEnabled: false, ponytailEnabled: false,
ponytailLevel: "full", ponytailLevel: "full",
pxpipeEnabled: false, pxpipeEnabled: false,
pxpipeAutoInstall: true, pxpipeAutoInstall: true,
pxpipeMinChars: 25000, pxpipeMinChars: 25000,
pxpipeTimeoutMs: 15000, pxpipeTimeoutMs: 15000,
}; };
async function readRaw() { async function readRaw() {
const db = await getAdapter(); const db = await getAdapter();
const row = db.get(`SELECT data FROM settings WHERE id = 1`); const row = db.get(`SELECT data FROM settings WHERE id = 1`);
return row ? parseJson(row.data, {}) : {}; return row ? parseJson(row.data, {}) : {};
} }
// Merge raw settings with defaults; backward-compat for missing keys // Merge raw settings with defaults; backward-compat for missing keys
function mergeWithDefaults(raw) { function mergeWithDefaults(raw) {
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) }; const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) { for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
if (merged[key] === undefined) { if (merged[key] === undefined) {
if ( if (
key === "outboundProxyEnabled" && key === "outboundProxyEnabled" &&
typeof merged.outboundProxyUrl === "string" && typeof merged.outboundProxyUrl === "string" &&
merged.outboundProxyUrl.trim() merged.outboundProxyUrl.trim()
) { ) {
merged[key] = true; merged[key] = true;
} else { } else {
merged[key] = defVal; merged[key] = defVal;
} }
} }
} }
return merged; return merged;
} }
export async function getSettings() { export async function getSettings() {
const raw = await readRaw(); const raw = await readRaw();
return mergeWithDefaults(raw); return mergeWithDefaults(raw);
} }
// Atomic read-merge-write inside transaction (prevents losing concurrent updates) // Atomic read-merge-write inside transaction (prevents losing concurrent updates)
export async function updateSettings(updates) { export async function updateSettings(updates) {
const db = await getAdapter(); const db = await getAdapter();
let next; let next;
db.transaction(() => { db.transaction(() => {
const row = db.get(`SELECT data FROM settings WHERE id = 1`); const row = db.get(`SELECT data FROM settings WHERE id = 1`);
const current = row ? parseJson(row.data, {}) : {}; const current = row ? parseJson(row.data, {}) : {};
next = { ...current, ...updates }; next = { ...current, ...updates };
db.run( db.run(
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`, `INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
[stringifyJson(next)] [stringifyJson(next)],
); );
}); });
return mergeWithDefaults(next); return mergeWithDefaults(next);
} }
export async function isCloudEnabled() { export async function isCloudEnabled() {
const settings = await getSettings(); const settings = await getSettings();
return settings.cloudEnabled === true; return settings.cloudEnabled === true;
} }
export async function getCloudUrl() { export async function getCloudUrl() {
const settings = await getSettings(); const settings = await getSettings();
return ( return (
settings.cloudUrl || settings.cloudUrl ||
process.env.CLOUD_URL || process.env.CLOUD_URL ||
process.env.NEXT_PUBLIC_CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL ||
"" ""
); );
} }
export async function exportSettings() { export async function exportSettings() {
return await readRaw(); return await readRaw();
} }

View File

@@ -115,7 +115,10 @@ describe("commandcode executor — early-error peek", () => {
c.close(); 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(); const text = await res.text();
expect(text).toContain("café"); expect(text).toContain("café");
expect(text).not.toContain("\uFFFD"); expect(text).not.toContain("\uFFFD");

View File

@@ -3,73 +3,88 @@ import { handleForcedSSEToJson } from "../../open-sse/handlers/chatCore/sseToJso
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const sseResponse = (chunks) => { const sseResponse = (chunks) => {
const body = new ReadableStream({ const body = new ReadableStream({
start(c) { start(c) {
for (const ch of chunks) c.enqueue(encoder.encode(`data: ${JSON.stringify(ch)}\n\n`)); for (const ch of chunks)
c.enqueue(encoder.encode("data: [DONE]\n\n")); c.enqueue(encoder.encode(`data: ${JSON.stringify(ch)}\n\n`));
c.close(); c.enqueue(encoder.encode("data: [DONE]\n\n"));
}, c.close();
}); },
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); });
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}; };
const mkChunk = (content, finish = null) => ({ const mkChunk = (content, finish = null) => ({
id: "x", id: "x",
object: "chat.completion.chunk", object: "chat.completion.chunk",
created: 1, created: 1,
model: "m", model: "m",
choices: [{ index: 0, delta: content ? { content } : {}, finish_reason: finish }], choices: [
{ index: 0, delta: content ? { content } : {}, finish_reason: finish },
],
}); });
const baseCtx = { const baseCtx = {
provider: "fakeprovider", provider: "fakeprovider",
model: "m", model: "m",
body: { stream: false }, body: { stream: false },
stream: true, stream: true,
translatedBody: null, translatedBody: null,
finalBody: null, finalBody: null,
requestStartTime: Date.now(), requestStartTime: Date.now(),
connectionId: "c1", connectionId: "c1",
apiKey: null, apiKey: null,
clientRawRequest: null, clientRawRequest: null,
onRequestSuccess: null, onRequestSuccess: null,
pxpipe: null, pxpipe: null,
reqTag: "", reqTag: "",
log: null, log: null,
trackDone: () => {}, trackDone: () => {},
appendLog: () => {}, appendLog: () => {},
reqLogger: null, reqLogger: null,
toolNameMap: null, toolNameMap: null,
sourceFormat: "openai", sourceFormat: "openai",
}; };
describe("Layer 1 — non-streaming stream error patterns", () => { describe("Layer 1 — non-streaming stream error patterns", () => {
it("returns a 502 error result when content matches a configured pattern", async () => { it("returns a 502 error result when content matches a configured pattern", async () => {
const result = await handleForcedSSEToJson({ const result = await handleForcedSSEToJson({
...baseCtx, ...baseCtx,
streamErrorPatterns: { fakeprovider: ["Network connection lost"] }, streamErrorPatterns: { fakeprovider: ["Network connection lost"] },
providerResponse: sseResponse([mkChunk("Network connection lost."), mkChunk(null, "stop")]), providerResponse: sseResponse([
}); mkChunk("Network connection lost."),
expect(result.success).toBe(false); mkChunk(null, "stop"),
expect(result.status).toBe(502); ]),
expect(result.error).toContain("Network connection lost"); });
}); 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 () => { it("succeeds when content does not match", async () => {
const result = await handleForcedSSEToJson({ const result = await handleForcedSSEToJson({
...baseCtx, ...baseCtx,
streamErrorPatterns: { fakeprovider: ["Network connection lost"] }, streamErrorPatterns: { fakeprovider: ["Network connection lost"] },
providerResponse: sseResponse([mkChunk("hello world"), mkChunk(null, "stop")]), providerResponse: sseResponse([
}); mkChunk("hello world"),
expect(result.success).toBe(true); mkChunk(null, "stop"),
}); ]),
});
expect(result.success).toBe(true);
});
it("ignores patterns for other providers", async () => { it("ignores patterns for other providers", async () => {
const result = await handleForcedSSEToJson({ const result = await handleForcedSSEToJson({
...baseCtx, ...baseCtx,
streamErrorPatterns: { otherprovider: ["hello"] }, streamErrorPatterns: { otherprovider: ["hello"] },
providerResponse: sseResponse([mkChunk("hello world"), mkChunk(null, "stop")]), providerResponse: sseResponse([
}); mkChunk("hello world"),
expect(result.success).toBe(true); mkChunk(null, "stop"),
}); ]),
});
expect(result.success).toBe(true);
});
}); });

View File

@@ -1,50 +1,70 @@
import { describe, it, expect } from "vitest"; 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", () => { describe("streamErrorPatterns util", () => {
it("matches plain text as case-insensitive substring", () => { it("matches plain text as case-insensitive substring", () => {
expect(matchStreamErrorPatterns(["Network connection lost"], "network CONNECTION LOST.")).toBe("Network connection lost"); expect(
expect(matchStreamErrorPatterns(["server_error"], "some normal content")).toBeNull(); matchStreamErrorPatterns(
}); ["Network connection lost"],
"network CONNECTION LOST.",
),
).toBe("Network connection lost");
expect(
matchStreamErrorPatterns(["server_error"], "some normal content"),
).toBeNull();
});
it("matches /regex/flags", () => { it("matches /regex/flags", () => {
expect(matchStreamErrorPatterns(["/generation failed.*retry/i"], "GENERATION FAILED. please RETRY")).toBe("/generation failed.*retry/i"); expect(
expect(matchStreamErrorPatterns(["/\\d+ tokens/"], "used 123 tokens")).toBe("/\\d+ tokens/"); 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", () => { it("skips invalid regex and empty entries", () => {
expect(matchStreamErrorPatterns(["/[unclosed/", "", " "], "anything")).toBeNull(); expect(
}); matchStreamErrorPatterns(["/[unclosed/", "", " "], "anything"),
).toBeNull();
});
it("returns null for empty patterns or text", () => { it("returns null for empty patterns or text", () => {
expect(matchStreamErrorPatterns([], "x")).toBeNull(); expect(matchStreamErrorPatterns([], "x")).toBeNull();
expect(matchStreamErrorPatterns(["x"], "")).toBeNull(); expect(matchStreamErrorPatterns(["x"], "")).toBeNull();
expect(matchStreamErrorPatterns(null, "x")).toBeNull(); expect(matchStreamErrorPatterns(null, "x")).toBeNull();
expect(matchStreamErrorPatterns(undefined, "x")).toBeNull(); expect(matchStreamErrorPatterns(undefined, "x")).toBeNull();
}); });
it("regex matching is stateless across calls", () => { it("regex matching is stateless across calls", () => {
const pats = ["/error/i"]; const pats = ["/error/i"];
expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i"); expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i");
expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i"); expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i");
}); });
it("parsePatterns normalizes entries", () => { it("parsePatterns normalizes entries", () => {
const parsed = parsePatterns(["Plain", "/re/g", "", "/bad["]); const parsed = parsePatterns(["Plain", "/re/g", "", "/bad["]);
expect(parsed.length).toBe(3); expect(parsed.length).toBe(3);
expect(parsed[0]).toEqual({ text: "plain", raw: "Plain" }); expect(parsed[0]).toEqual({ text: "plain", raw: "Plain" });
expect(parsed[1].regex).toBeInstanceOf(RegExp); expect(parsed[1].regex).toBeInstanceOf(RegExp);
expect(parsed[2]).toEqual({ text: "/bad[", raw: "/bad[" }); expect(parsed[2]).toEqual({ text: "/bad[", raw: "/bad[" });
}); });
it("skips entries that look like regex but fail to compile", () => { it("skips entries that look like regex but fail to compile", () => {
const parsed = parsePatterns(["/[unclosed/", "/ok/g"]); const parsed = parsePatterns(["/[unclosed/", "/ok/g"]);
expect(parsed.length).toBe(1); expect(parsed.length).toBe(1);
expect(parsed[0].raw).toBe("/ok/g"); expect(parsed[0].raw).toBe("/ok/g");
}); });
it("streamStatusForContent maps match to error status", () => { it("streamStatusForContent maps match to error status", () => {
expect(streamStatusForContent(["boom"], "a boom happened")).toBe("error"); expect(streamStatusForContent(["boom"], "a boom happened")).toBe("error");
expect(streamStatusForContent(["boom"], "all good")).toBe("success"); expect(streamStatusForContent(["boom"], "all good")).toBe("success");
}); });
}); });