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:
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user