mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Update jsconfig.json and package.json to correct open-sse path references from relative to local directory.
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { detectFormat } from "../services/provider.js";
|
||||
import { translateResponse, initState } from "../translator/index.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
import { SKIP_PATTERNS } from "../config/constants.js";
|
||||
import { formatSSE } from "./stream.js";
|
||||
|
||||
/**
|
||||
* Check for bypass patterns (warmup, skip) - return fake response without calling provider
|
||||
* Supports both streaming and non-streaming responses
|
||||
* Returns response in the correct sourceFormat using translator
|
||||
*
|
||||
* @param {object} body - Request body
|
||||
* @param {string} model - Model name
|
||||
* @returns {object|null} { success: true, response: Response } or null if not bypass
|
||||
*/
|
||||
export function handleBypassRequest(body, model) {
|
||||
const messages = body.messages;
|
||||
if (!messages?.length) return null;
|
||||
|
||||
// Helper to extract text from content
|
||||
const getText = (content) => {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter(c => c.type === "text").map(c => c.text).join(" ");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
let shouldBypass = false;
|
||||
|
||||
// Check warmup: first message "Warmup"
|
||||
const firstText = getText(messages[0]?.content);
|
||||
if (firstText === "Warmup") shouldBypass = true;
|
||||
|
||||
// Check count pattern: [{"role":"user","content":"count"}]
|
||||
if (!shouldBypass &&
|
||||
messages.length === 1 &&
|
||||
messages[0]?.role === "user" &&
|
||||
firstText === "count") {
|
||||
shouldBypass = true;
|
||||
}
|
||||
|
||||
// Check skip patterns
|
||||
if (!shouldBypass && SKIP_PATTERNS?.length) {
|
||||
const allText = messages.map(m => getText(m.content)).join(" ");
|
||||
shouldBypass = SKIP_PATTERNS.some(p => allText.includes(p));
|
||||
}
|
||||
|
||||
if (!shouldBypass) return null;
|
||||
|
||||
// Detect source format and stream mode
|
||||
const sourceFormat = detectFormat(body);
|
||||
const stream = body.stream !== false;
|
||||
|
||||
// Create bypass response using translator
|
||||
if (stream) {
|
||||
return createStreamingResponse(sourceFormat, model);
|
||||
} else {
|
||||
return createNonStreamingResponse(sourceFormat, model);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenAI standard format response
|
||||
*/
|
||||
function createOpenAIResponse(model) {
|
||||
const id = `chatcmpl-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
const text = "CLI Command Execution: Clear Terminal";
|
||||
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: text
|
||||
},
|
||||
finish_reason: "stop"
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 2
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create non-streaming response with translation
|
||||
* Use translator to convert OpenAI → sourceFormat
|
||||
*/
|
||||
function createNonStreamingResponse(sourceFormat, model) {
|
||||
const openaiResponse = createOpenAIResponse(model);
|
||||
|
||||
// If sourceFormat is OpenAI, return directly
|
||||
if (sourceFormat === FORMATS.OPENAI) {
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(openaiResponse), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// Use translator to convert: simulate streaming then collect all chunks
|
||||
const state = initState(sourceFormat);
|
||||
state.model = model;
|
||||
|
||||
const openaiChunks = createOpenAIStreamingChunks(openaiResponse);
|
||||
const allTranslated = [];
|
||||
|
||||
for (const chunk of openaiChunks) {
|
||||
const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state);
|
||||
if (translated?.length > 0) {
|
||||
allTranslated.push(...translated);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state);
|
||||
if (flushed?.length > 0) {
|
||||
allTranslated.push(...flushed);
|
||||
}
|
||||
|
||||
// For non-streaming, merge all chunks into final response
|
||||
const finalResponse = mergeChunksToResponse(allTranslated, sourceFormat);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(finalResponse), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create streaming response with translation
|
||||
* Use translator to convert OpenAI chunks → sourceFormat
|
||||
*/
|
||||
function createStreamingResponse(sourceFormat, model) {
|
||||
const openaiResponse = createOpenAIResponse(model);
|
||||
const state = initState(sourceFormat);
|
||||
state.model = model;
|
||||
|
||||
// Create OpenAI streaming chunks
|
||||
const openaiChunks = createOpenAIStreamingChunks(openaiResponse);
|
||||
|
||||
// Translate each chunk to sourceFormat using translator
|
||||
const translatedChunks = [];
|
||||
|
||||
for (const chunk of openaiChunks) {
|
||||
const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state);
|
||||
if (translated?.length > 0) {
|
||||
for (const item of translated) {
|
||||
translatedChunks.push(formatSSE(item, sourceFormat));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining events
|
||||
const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state);
|
||||
if (flushed?.length > 0) {
|
||||
for (const item of flushed) {
|
||||
translatedChunks.push(formatSSE(item, sourceFormat));
|
||||
}
|
||||
}
|
||||
|
||||
// Add [DONE]
|
||||
translatedChunks.push("data: [DONE]\n\n");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(translatedChunks.join(""), {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge translated chunks into final response object (for non-streaming)
|
||||
* Takes the last complete chunk as the final response
|
||||
*/
|
||||
function mergeChunksToResponse(chunks, sourceFormat) {
|
||||
if (!chunks || chunks.length === 0) {
|
||||
return createOpenAIResponse("unknown");
|
||||
}
|
||||
|
||||
// For most formats, the last chunk before done contains the complete response
|
||||
// Find the most complete chunk (usually the last one with content)
|
||||
let finalChunk = chunks[chunks.length - 1];
|
||||
|
||||
// For Claude format, find the message_stop or final message
|
||||
if (sourceFormat === FORMATS.CLAUDE) {
|
||||
const messageStop = chunks.find(c => c.type === "message_stop");
|
||||
if (messageStop) {
|
||||
// Reconstruct complete message from chunks
|
||||
const contentDelta = chunks.find(c => c.type === "content_block_delta");
|
||||
const messageDelta = chunks.find(c => c.type === "message_delta");
|
||||
const messageStart = chunks.find(c => c.type === "message_start");
|
||||
|
||||
if (messageStart?.message) {
|
||||
finalChunk = messageStart.message;
|
||||
// Merge usage if available
|
||||
if (messageDelta?.usage) {
|
||||
finalChunk.usage = messageDelta.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenAI streaming chunks from complete response
|
||||
*/
|
||||
function createOpenAIStreamingChunks(completeResponse) {
|
||||
const { id, created, model, choices } = completeResponse;
|
||||
const content = choices[0].message.content;
|
||||
|
||||
return [
|
||||
// Chunk with content
|
||||
{
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content
|
||||
},
|
||||
finish_reason: null
|
||||
}]
|
||||
},
|
||||
// Final chunk with finish_reason
|
||||
{
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: "stop"
|
||||
}],
|
||||
usage: completeResponse.usage
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// OpenAI-compatible error types mapping
|
||||
const ERROR_TYPES = {
|
||||
400: { type: "invalid_request_error", code: "bad_request" },
|
||||
401: { type: "authentication_error", code: "invalid_api_key" },
|
||||
403: { type: "permission_error", code: "insufficient_quota" },
|
||||
404: { type: "invalid_request_error", code: "model_not_found" },
|
||||
429: { type: "rate_limit_error", code: "rate_limit_exceeded" },
|
||||
500: { type: "server_error", code: "internal_server_error" },
|
||||
502: { type: "server_error", code: "bad_gateway" },
|
||||
503: { type: "server_error", code: "service_unavailable" },
|
||||
504: { type: "server_error", code: "gateway_timeout" }
|
||||
};
|
||||
|
||||
/**
|
||||
* Build OpenAI-compatible error response body
|
||||
* @param {number} statusCode - HTTP status code
|
||||
* @param {string} message - Error message
|
||||
* @returns {object} Error response object
|
||||
*/
|
||||
export function buildErrorBody(statusCode, message) {
|
||||
const errorInfo = ERROR_TYPES[statusCode] ||
|
||||
(statusCode >= 500
|
||||
? { type: "server_error", code: "internal_server_error" }
|
||||
: { type: "invalid_request_error", code: "" });
|
||||
|
||||
return {
|
||||
error: {
|
||||
message: message || getDefaultMessage(statusCode),
|
||||
type: errorInfo.type,
|
||||
code: errorInfo.code
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default error message for status code
|
||||
*/
|
||||
function getDefaultMessage(statusCode) {
|
||||
const messages = {
|
||||
400: "Bad request",
|
||||
401: "Invalid API key provided",
|
||||
403: "You exceeded your current quota",
|
||||
404: "Model not found",
|
||||
429: "Rate limit exceeded",
|
||||
500: "Internal server error",
|
||||
502: "Bad gateway - upstream provider error",
|
||||
503: "Service temporarily unavailable",
|
||||
504: "Gateway timeout"
|
||||
};
|
||||
return messages[statusCode] || "An error occurred";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error Response object (for non-streaming)
|
||||
* @param {number} statusCode - HTTP status code
|
||||
* @param {string} message - Error message
|
||||
* @returns {Response} HTTP Response object
|
||||
*/
|
||||
export function errorResponse(statusCode, message) {
|
||||
return new Response(JSON.stringify(buildErrorBody(statusCode, message)), {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write error to SSE stream (for streaming)
|
||||
* @param {WritableStreamDefaultWriter} writer - Stream writer
|
||||
* @param {number} statusCode - HTTP status code
|
||||
* @param {string} message - Error message
|
||||
*/
|
||||
export async function writeStreamError(writer, statusCode, message) {
|
||||
const errorBody = buildErrorBody(statusCode, message);
|
||||
const encoder = new TextEncoder();
|
||||
await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse upstream provider error response
|
||||
* @param {Response} response - Fetch response from provider
|
||||
* @returns {Promise<{statusCode: number, message: string}>}
|
||||
*/
|
||||
export async function parseUpstreamError(response) {
|
||||
let message = "";
|
||||
|
||||
try {
|
||||
const text = await response.text();
|
||||
|
||||
// Try parse as JSON
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
message = json.error?.message || json.message || json.error || text;
|
||||
} catch {
|
||||
message = text;
|
||||
}
|
||||
} catch {
|
||||
message = `Upstream error: ${response.status}`;
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
message: typeof message === "string" ? message : JSON.stringify(message)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error result for chatCore handler
|
||||
* @param {number} statusCode - HTTP status code
|
||||
* @param {string} message - Error message
|
||||
* @returns {{ success: false, status: number, error: string, response: Response }}
|
||||
*/
|
||||
export function createErrorResult(statusCode, message) {
|
||||
return {
|
||||
success: false,
|
||||
status: statusCode,
|
||||
error: message,
|
||||
response: errorResponse(statusCode, message)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format provider error with context
|
||||
* @param {Error} error - Original error
|
||||
* @param {string} provider - Provider name
|
||||
* @param {string} model - Model name
|
||||
* @returns {string} Formatted error message
|
||||
*/
|
||||
export function formatProviderError(error, provider, model) {
|
||||
return error.message || "Unknown error";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Transform OpenAI SSE stream to Ollama JSON lines format
|
||||
export function transformToOllama(response, model) {
|
||||
let buffer = "";
|
||||
let pendingToolCalls = {};
|
||||
|
||||
const transform = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
const text = new TextDecoder().decode(chunk);
|
||||
buffer += text;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
|
||||
if (data === "[DONE]") {
|
||||
const ollamaEnd = JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) + "\n";
|
||||
controller.enqueue(new TextEncoder().encode(ollamaEnd));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta || {};
|
||||
const content = delta.content || "";
|
||||
const toolCalls = delta.tool_calls;
|
||||
|
||||
if (toolCalls) {
|
||||
for (const tc of toolCalls) {
|
||||
const idx = tc.index;
|
||||
if (!pendingToolCalls[idx]) {
|
||||
pendingToolCalls[idx] = { id: tc.id, function: { name: "", arguments: "" } };
|
||||
}
|
||||
if (tc.function?.name) pendingToolCalls[idx].function.name += tc.function.name;
|
||||
if (tc.function?.arguments) pendingToolCalls[idx].function.arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const ollama = JSON.stringify({ model, message: { role: "assistant", content }, done: false }) + "\n";
|
||||
controller.enqueue(new TextEncoder().encode(ollama));
|
||||
}
|
||||
|
||||
const finishReason = parsed.choices?.[0]?.finish_reason;
|
||||
if (finishReason === "tool_calls" || finishReason === "stop") {
|
||||
const toolCallsArr = Object.values(pendingToolCalls);
|
||||
if (toolCallsArr.length > 0) {
|
||||
const formattedCalls = toolCallsArr.map(tc => ({
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: JSON.parse(tc.function.arguments || "{}")
|
||||
}
|
||||
}));
|
||||
const ollama = JSON.stringify({
|
||||
model,
|
||||
message: { role: "assistant", content: "", tool_calls: formattedCalls },
|
||||
done: true
|
||||
}) + "\n";
|
||||
controller.enqueue(new TextEncoder().encode(ollama));
|
||||
pendingToolCalls = {};
|
||||
} else if (finishReason === "stop") {
|
||||
const ollamaEnd = JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) + "\n";
|
||||
controller.enqueue(new TextEncoder().encode(ollamaEnd));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore parse errors
|
||||
}
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
const ollamaEnd = JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) + "\n";
|
||||
controller.enqueue(new TextEncoder().encode(ollamaEnd));
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(response.body.pipeThrough(transform), {
|
||||
headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// Check if running in Node.js environment (has fs module)
|
||||
const isNode = typeof process !== "undefined" && process.versions?.node && typeof window === "undefined";
|
||||
|
||||
// Check if logging is enabled via environment variable (default: false)
|
||||
const LOGGING_ENABLED = typeof process !== "undefined" && process.env?.ENABLE_REQUEST_LOGS === 'true';
|
||||
|
||||
let fs = null;
|
||||
let path = null;
|
||||
let LOGS_DIR = null;
|
||||
|
||||
// Lazy load Node.js modules (avoid top-level await)
|
||||
async function ensureNodeModules() {
|
||||
if (!isNode || !LOGGING_ENABLED || fs) return;
|
||||
try {
|
||||
fs = await import("fs");
|
||||
path = await import("path");
|
||||
LOGS_DIR = path.join(typeof process !== "undefined" && process.cwd ? process.cwd() : ".", "logs");
|
||||
} catch {
|
||||
// Running in non-Node environment (Worker, Browser, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// Format timestamp for folder name: 20251228_143045
|
||||
function formatTimestamp(date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const y = date.getFullYear();
|
||||
const m = pad(date.getMonth() + 1);
|
||||
const d = pad(date.getDate());
|
||||
const h = pad(date.getHours());
|
||||
const min = pad(date.getMinutes());
|
||||
const s = pad(date.getSeconds());
|
||||
return `${y}${m}${d}_${h}${min}${s}`;
|
||||
}
|
||||
|
||||
// Create log session folder: {sourceFormat}_{targetFormat}_{model}_{timestamp}
|
||||
async function createLogSession(sourceFormat, targetFormat, model) {
|
||||
await ensureNodeModules();
|
||||
if (!fs || !LOGS_DIR) return null;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(LOGS_DIR)) {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = formatTimestamp();
|
||||
const safeModel = model.replace(/[/:]/g, "-");
|
||||
const folderName = `${sourceFormat}_${targetFormat}_${safeModel}_${timestamp}`;
|
||||
const sessionPath = path.join(LOGS_DIR, folderName);
|
||||
|
||||
fs.mkdirSync(sessionPath, { recursive: true });
|
||||
|
||||
return sessionPath;
|
||||
} catch (err) {
|
||||
console.log("[LOG] Failed to create log session:", err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Write JSON file
|
||||
function writeJsonFile(sessionPath, filename, data) {
|
||||
if (!fs || !sessionPath) return;
|
||||
|
||||
try {
|
||||
const filePath = path.join(sessionPath, filename);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
console.log(`[LOG] Failed to write ${filename}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Mask sensitive data in headers
|
||||
function maskSensitiveHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
const masked = { ...headers };
|
||||
const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token"];
|
||||
|
||||
for (const key of Object.keys(masked)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) {
|
||||
const value = masked[key];
|
||||
if (value && value.length > 20) {
|
||||
masked[key] = value.slice(0, 10) + "..." + value.slice(-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
// No-op logger when logging is disabled
|
||||
function createNoOpLogger() {
|
||||
return {
|
||||
sessionPath: null,
|
||||
logClientRawRequest() {},
|
||||
logRawRequest() {},
|
||||
logFormatInfo() {},
|
||||
logConvertedRequest() {},
|
||||
logRawResponse() {},
|
||||
logConvertedResponse() {},
|
||||
logStreamChunk() {},
|
||||
logStreamComplete() {},
|
||||
logError() {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new log session and return logger functions
|
||||
* @param {string} sourceFormat - Source format from client (claude, openai, etc.)
|
||||
* @param {string} targetFormat - Target format to provider (antigravity, gemini-cli, etc.)
|
||||
* @param {string} model - Model name
|
||||
* @returns {Promise<object>} Promise that resolves to logger object with methods to log each stage
|
||||
*/
|
||||
export async function createRequestLogger(sourceFormat, targetFormat, model) {
|
||||
// Return no-op logger if logging is disabled
|
||||
if (!LOGGING_ENABLED) {
|
||||
return createNoOpLogger();
|
||||
}
|
||||
|
||||
// Wait for session to be created before returning logger
|
||||
const sessionPath = await createLogSession(sourceFormat, targetFormat, model);
|
||||
|
||||
return {
|
||||
get sessionPath() { return sessionPath; },
|
||||
|
||||
// 0. Log client raw request (before any conversion)
|
||||
logClientRawRequest(endpoint, body, headers = {}) {
|
||||
writeJsonFile(sessionPath, "0_client_raw_request.json", {
|
||||
timestamp: new Date().toISOString(),
|
||||
endpoint,
|
||||
headers: maskSensitiveHeaders(headers),
|
||||
body
|
||||
});
|
||||
},
|
||||
|
||||
// 1. Log raw request from client (after initial conversion like responsesApi)
|
||||
logRawRequest(body, headers = {}) {
|
||||
writeJsonFile(sessionPath, "1_raw_request.json", {
|
||||
timestamp: new Date().toISOString(),
|
||||
headers: maskSensitiveHeaders(headers),
|
||||
body
|
||||
});
|
||||
},
|
||||
|
||||
// 1a. Log format detection info
|
||||
logFormatInfo(info) {
|
||||
writeJsonFile(sessionPath, "1a_format_info.json", {
|
||||
timestamp: new Date().toISOString(),
|
||||
...info
|
||||
});
|
||||
},
|
||||
|
||||
// 2. Log converted request to send to provider
|
||||
logConvertedRequest(url, headers, body) {
|
||||
writeJsonFile(sessionPath, "2_converted_request.json", {
|
||||
timestamp: new Date().toISOString(),
|
||||
url,
|
||||
headers: maskSensitiveHeaders(headers),
|
||||
body
|
||||
});
|
||||
},
|
||||
|
||||
// 3. Log provider response (for non-streaming or error)
|
||||
logProviderResponse(status, statusText, headers, body) {
|
||||
const filename = "3_provider_response.json";
|
||||
writeJsonFile(sessionPath, filename, {
|
||||
timestamp: new Date().toISOString(),
|
||||
status,
|
||||
statusText,
|
||||
headers: headers ? (typeof headers.entries === "function" ? Object.fromEntries(headers.entries()) : headers) : {},
|
||||
body
|
||||
});
|
||||
},
|
||||
|
||||
// 3. Append streaming chunk to provider response
|
||||
appendProviderChunk(chunk) {
|
||||
if (!fs || !sessionPath) return;
|
||||
try {
|
||||
const filePath = path.join(sessionPath, "3_provider_response.txt");
|
||||
fs.appendFileSync(filePath, chunk);
|
||||
} catch (err) {
|
||||
// Ignore append errors
|
||||
}
|
||||
},
|
||||
|
||||
// 4. Log converted response to client (for non-streaming)
|
||||
logConvertedResponse(body) {
|
||||
writeJsonFile(sessionPath, "4_converted_response.json", {
|
||||
timestamp: new Date().toISOString(),
|
||||
body
|
||||
});
|
||||
},
|
||||
|
||||
// 4. Append streaming chunk to converted response
|
||||
appendConvertedChunk(chunk) {
|
||||
if (!fs || !sessionPath) return;
|
||||
try {
|
||||
const filePath = path.join(sessionPath, "4_converted_response.txt");
|
||||
fs.appendFileSync(filePath, chunk);
|
||||
} catch (err) {
|
||||
// Ignore append errors
|
||||
}
|
||||
},
|
||||
|
||||
// 5. Log error
|
||||
logError(error, requestBody = null) {
|
||||
writeJsonFile(sessionPath, "5_error.json", {
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error?.message || String(error),
|
||||
stack: error?.stack,
|
||||
requestBody
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy functions for backward compatibility
|
||||
export function logRequest() {}
|
||||
export function logResponse() {}
|
||||
export function logError(provider, { error, url, model, requestBody }) {
|
||||
if (!fs || !LOGS_DIR) return;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(LOGS_DIR)) {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
const logPath = path.join(LOGS_DIR, `${provider}-${date}.log`);
|
||||
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "error",
|
||||
provider,
|
||||
model,
|
||||
url,
|
||||
error: error?.message || String(error),
|
||||
stack: error?.stack,
|
||||
requestBody
|
||||
};
|
||||
|
||||
fs.appendFileSync(logPath, JSON.stringify(logEntry) + "\n");
|
||||
} catch (err) {
|
||||
console.log("[LOG] Failed to write error log:", err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { translateResponse, initState } from "../translator/index.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
|
||||
// Get HH:MM timestamp
|
||||
function getTimeString() {
|
||||
return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// Extract usage from any format (Claude, OpenAI, Gemini)
|
||||
function extractUsage(chunk) {
|
||||
// Claude format (message_delta event)
|
||||
if (chunk.type === "message_delta" && chunk.usage) {
|
||||
return {
|
||||
prompt_tokens: chunk.usage.input_tokens || 0,
|
||||
completion_tokens: chunk.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: chunk.usage.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens
|
||||
};
|
||||
}
|
||||
// OpenAI format
|
||||
if (chunk.usage?.prompt_tokens !== undefined) {
|
||||
return {
|
||||
prompt_tokens: chunk.usage.prompt_tokens,
|
||||
completion_tokens: chunk.usage.completion_tokens || 0,
|
||||
cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens,
|
||||
reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens
|
||||
};
|
||||
}
|
||||
// Gemini format
|
||||
if (chunk.usageMetadata) {
|
||||
return {
|
||||
prompt_tokens: chunk.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: chunk.usageMetadata.candidatesTokenCount || 0,
|
||||
reasoning_tokens: chunk.usageMetadata.thoughtsTokenCount
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ANSI color codes
|
||||
export const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
cyan: "\x1b[36m"
|
||||
};
|
||||
|
||||
// Log usage with cache info (green color)
|
||||
function logUsage(provider, usage) {
|
||||
if (!usage) return;
|
||||
|
||||
const p = provider?.toUpperCase() || "UNKNOWN";
|
||||
const inTokens = usage.prompt_tokens || 0;
|
||||
const outTokens = usage.completion_tokens || 0;
|
||||
|
||||
let msg = `[${getTimeString()}] 📊 [USAGE] ${p} | in=${inTokens} | out=${outTokens}`;
|
||||
|
||||
if (usage.cache_creation_input_tokens) msg += ` | cache_write=${usage.cache_creation_input_tokens}`;
|
||||
if (usage.cache_read_input_tokens) msg += ` | cache_read=${usage.cache_read_input_tokens}`;
|
||||
if (usage.cached_tokens) msg += ` | cached=${usage.cached_tokens}`;
|
||||
if (usage.reasoning_tokens) msg += ` | reasoning=${usage.reasoning_tokens}`;
|
||||
|
||||
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
||||
}
|
||||
|
||||
// Parse SSE data line
|
||||
function parseSSELine(line) {
|
||||
if (!line || !line.startsWith("data:")) return null;
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") return { done: true };
|
||||
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
// Log parse errors for debugging incomplete chunks
|
||||
if (data.length > 0 && data.length < 1000) {
|
||||
console.log(`[WARN] Failed to parse SSE line (${data.length} chars): ${data.substring(0, 100)}...`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format output as SSE
|
||||
* @param {object} data - Data to format
|
||||
* @param {string} sourceFormat - Target format for client
|
||||
* @returns {string} SSE formatted string
|
||||
*/
|
||||
export function formatSSE(data, sourceFormat) {
|
||||
if (data.done) return "data: [DONE]\n\n";
|
||||
|
||||
// OpenAI Responses API format: has event field
|
||||
if (data.event && data.data) {
|
||||
return `event: ${data.event}\ndata: ${JSON.stringify(data.data)}\n\n`;
|
||||
}
|
||||
|
||||
// Claude format: include event prefix
|
||||
if (sourceFormat === FORMATS.CLAUDE && data.type) {
|
||||
return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream modes
|
||||
*/
|
||||
const STREAM_MODE = {
|
||||
TRANSLATE: "translate", // Full translation between formats
|
||||
PASSTHROUGH: "passthrough" // No translation, normalize output, extract usage
|
||||
};
|
||||
|
||||
/**
|
||||
* Create unified SSE transform stream
|
||||
* @param {object} options
|
||||
* @param {string} options.mode - Stream mode: translate, passthrough
|
||||
* @param {string} options.targetFormat - Provider format (for translate mode)
|
||||
* @param {string} options.sourceFormat - Client format (for translate mode)
|
||||
* @param {string} options.provider - Provider name
|
||||
* @param {object} options.reqLogger - Request logger instance
|
||||
*/
|
||||
export function createSSEStream(options = {}) {
|
||||
const {
|
||||
mode = STREAM_MODE.TRANSLATE,
|
||||
targetFormat,
|
||||
sourceFormat,
|
||||
provider = null,
|
||||
reqLogger = null
|
||||
} = options;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = "";
|
||||
let usage = null;
|
||||
|
||||
// State for translate mode
|
||||
const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider } : null;
|
||||
|
||||
return new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
const text = decoder.decode(chunk, { stream: true });
|
||||
buffer += text;
|
||||
reqLogger?.appendProviderChunk?.(text);
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Passthrough mode: normalize and forward
|
||||
if (mode === STREAM_MODE.PASSTHROUGH) {
|
||||
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed.slice(5).trim());
|
||||
const extracted = extractUsage(parsed);
|
||||
if (extracted) usage = extracted;
|
||||
} catch {}
|
||||
}
|
||||
// Normalize: ensure "data: " has space
|
||||
let output;
|
||||
if (line.startsWith("data:") && !line.startsWith("data: ")) {
|
||||
output = "data: " + line.slice(5) + "\n";
|
||||
} else {
|
||||
output = line + "\n";
|
||||
}
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(encoder.encode(output));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Translate mode
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parsed = parseSSELine(trimmed);
|
||||
if (!parsed) continue;
|
||||
|
||||
if (parsed.done) {
|
||||
const output = "data: [DONE]\n\n";
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(encoder.encode(output));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract usage
|
||||
const extracted = extractUsage(parsed);
|
||||
if (extracted) state.usage = extracted;
|
||||
|
||||
// Translate and emit
|
||||
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
|
||||
if (translated?.length > 0) {
|
||||
for (const item of translated) {
|
||||
const output = formatSSE(item, sourceFormat);
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(encoder.encode(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
try {
|
||||
const remaining = decoder.decode();
|
||||
if (remaining) buffer += remaining;
|
||||
|
||||
if (mode === STREAM_MODE.PASSTHROUGH) {
|
||||
if (buffer) {
|
||||
let output = buffer;
|
||||
if (buffer.startsWith("data:") && !buffer.startsWith("data: ")) {
|
||||
output = "data: " + buffer.slice(5);
|
||||
}
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(encoder.encode(output));
|
||||
}
|
||||
if (usage) logUsage(provider, usage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Translate mode: process remaining buffer
|
||||
if (buffer.trim()) {
|
||||
const parsed = parseSSELine(buffer.trim());
|
||||
if (parsed && !parsed.done) {
|
||||
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
|
||||
if (translated?.length > 0) {
|
||||
for (const item of translated) {
|
||||
const output = formatSSE(item, sourceFormat);
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(encoder.encode(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining events (only once at stream end)
|
||||
const flushed = translateResponse(targetFormat, sourceFormat, null, state);
|
||||
if (flushed?.length > 0) {
|
||||
for (const item of flushed) {
|
||||
const output = formatSSE(item, sourceFormat);
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(encoder.encode(output));
|
||||
}
|
||||
}
|
||||
|
||||
// Send [DONE] and log usage
|
||||
const doneOutput = "data: [DONE]\n\n";
|
||||
reqLogger?.appendConvertedChunk?.(doneOutput);
|
||||
controller.enqueue(encoder.encode(doneOutput));
|
||||
|
||||
if (state?.usage) logUsage(state.provider || targetFormat, state.usage);
|
||||
} catch (error) {
|
||||
console.log("Error in flush:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience functions for backward compatibility
|
||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null) {
|
||||
return createSSEStream({
|
||||
mode: STREAM_MODE.TRANSLATE,
|
||||
targetFormat,
|
||||
sourceFormat,
|
||||
provider,
|
||||
reqLogger
|
||||
});
|
||||
}
|
||||
|
||||
export function createPassthroughStreamWithLogger(provider = null, reqLogger = null) {
|
||||
return createSSEStream({
|
||||
mode: STREAM_MODE.PASSTHROUGH,
|
||||
provider,
|
||||
reqLogger
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Stream handler with disconnect detection - shared for all providers
|
||||
|
||||
// Get HH:MM timestamp
|
||||
function getTimeString() {
|
||||
return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create stream controller with abort and disconnect detection
|
||||
* @param {object} options
|
||||
* @param {function} options.onDisconnect - Callback when client disconnects
|
||||
* @param {object} options.log - Logger instance
|
||||
* @param {string} options.provider - Provider name
|
||||
* @param {string} options.model - Model name
|
||||
*/
|
||||
export function createStreamController({ onDisconnect, log, provider, model } = {}) {
|
||||
const abortController = new AbortController();
|
||||
const startTime = Date.now();
|
||||
let disconnected = false;
|
||||
let abortTimeout = null;
|
||||
|
||||
const logStream = (status) => {
|
||||
const duration = Date.now() - startTime;
|
||||
const p = provider?.toUpperCase() || "UNKNOWN";
|
||||
console.log(`[${getTimeString()}] 🌊 [STREAM] ${p} | ${model || "unknown"} | ${duration}ms | ${status}`);
|
||||
};
|
||||
|
||||
return {
|
||||
signal: abortController.signal,
|
||||
startTime,
|
||||
|
||||
isConnected: () => !disconnected,
|
||||
|
||||
// Call when client disconnects
|
||||
handleDisconnect: (reason = "client_closed") => {
|
||||
if (disconnected) return;
|
||||
disconnected = true;
|
||||
|
||||
logStream(`disconnect: ${reason}`);
|
||||
|
||||
// Delay abort to allow cleanup
|
||||
abortTimeout = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, 500);
|
||||
|
||||
onDisconnect?.({ reason, duration: Date.now() - startTime });
|
||||
},
|
||||
|
||||
// Call when stream completes normally
|
||||
handleComplete: () => {
|
||||
if (disconnected) return;
|
||||
disconnected = true;
|
||||
|
||||
logStream("complete");
|
||||
|
||||
if (abortTimeout) {
|
||||
clearTimeout(abortTimeout);
|
||||
abortTimeout = null;
|
||||
}
|
||||
},
|
||||
|
||||
// Call on error
|
||||
handleError: (error) => {
|
||||
if (abortTimeout) {
|
||||
clearTimeout(abortTimeout);
|
||||
abortTimeout = null;
|
||||
}
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
logStream("aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
logStream(`error: ${error.message}`);
|
||||
},
|
||||
|
||||
abort: () => abortController.abort()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create transform stream with disconnect detection
|
||||
* Wraps existing transform stream and adds abort capability
|
||||
*/
|
||||
export function createDisconnectAwareStream(transformStream, streamController) {
|
||||
const reader = transformStream.readable.getReader();
|
||||
const writer = transformStream.writable.getWriter();
|
||||
|
||||
return new ReadableStream({
|
||||
async pull(controller) {
|
||||
if (!streamController.isConnected()) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
streamController.handleComplete();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(value);
|
||||
} catch (error) {
|
||||
streamController.handleError(error);
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
cancel(reason) {
|
||||
streamController.handleDisconnect(reason || "cancelled");
|
||||
reader.cancel();
|
||||
writer.abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipe provider response through transform with disconnect detection
|
||||
* @param {Response} providerResponse - Response from provider
|
||||
* @param {TransformStream} transformStream - Transform stream for SSE
|
||||
* @param {object} streamController - Stream controller from createStreamController
|
||||
*/
|
||||
export function pipeWithDisconnect(providerResponse, transformStream, streamController) {
|
||||
const transformedBody = providerResponse.body.pipeThrough(transformStream);
|
||||
return createDisconnectAwareStream(
|
||||
{ readable: transformedBody, writable: { getWriter: () => ({ abort: () => {} }) } },
|
||||
streamController
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user