Update jsconfig.json and package.json to correct open-sse path references from relative to local directory.

This commit is contained in:
decolua
2026-01-05 10:37:09 +07:00
parent 3857598de4
commit e35421beb1
39 changed files with 6846 additions and 6 deletions
+266
View File
@@ -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
}
];
}
+133
View File
@@ -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";
}
+82
View File
@@ -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": "*" }
});
}
+244
View File
@@ -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);
}
}
+278
View File
@@ -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
});
}
+131
View File
@@ -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
);
}