Merge remote-tracking branch 'origin/master' into gitea/feature/end
Resolved conflicts taking origin/master (v0.5.55) as canonical, with local features re-applied: - runtime log level (LOG_LEVEL env + dashboard Settings → Logging, applied immediately and persisted across restarts) - free/noAuth provider enable/disable toggle via providerStrategies.enabled - parallel model testing (Test All Models / Test Selected Keys)
This commit is contained in:
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Singleton cache for real Claude Code client headers.
|
||||
* Captures headers from authentic Claude Code requests and makes them available
|
||||
* for forwarding to api.anthropic.com, replacing static hardcoded values.
|
||||
*/
|
||||
|
||||
const CLAUDE_IDENTITY_HEADERS = [
|
||||
"user-agent",
|
||||
"anthropic-beta",
|
||||
"anthropic-version",
|
||||
"anthropic-dangerous-direct-browser-access",
|
||||
"x-app",
|
||||
"x-stainless-helper-method",
|
||||
"x-stainless-retry-count",
|
||||
"x-stainless-runtime-version",
|
||||
"x-stainless-package-version",
|
||||
"x-stainless-runtime",
|
||||
"x-stainless-lang",
|
||||
"x-stainless-arch",
|
||||
"x-stainless-os",
|
||||
"x-stainless-timeout",
|
||||
"x-claude-code-session-id",
|
||||
"package-version",
|
||||
"runtime-version",
|
||||
"os",
|
||||
"arch",
|
||||
];
|
||||
|
||||
let cachedHeaders = null;
|
||||
|
||||
/**
|
||||
* Detect if request headers look like a real Claude Code client.
|
||||
* @param {object} headers - Lowercase header key/value object
|
||||
*/
|
||||
function isClaudeCodeClient(headers) {
|
||||
const ua = (headers["user-agent"] || "").toLowerCase();
|
||||
const xApp = (headers["x-app"] || "").toLowerCase();
|
||||
return ua.includes("claude-cli") || ua.includes("claude-code") || xApp === "cli";
|
||||
}
|
||||
|
||||
/**
|
||||
* Store Claude Code identity headers if this looks like a real client request.
|
||||
* Called at the entry point before any translation/forwarding.
|
||||
* @param {object} headers - Lowercase header key/value object (from request.headers.entries())
|
||||
*/
|
||||
export function cacheClaudeHeaders(headers) {
|
||||
if (!headers || typeof headers !== "object") return;
|
||||
if (!isClaudeCodeClient(headers)) return;
|
||||
|
||||
const captured = {};
|
||||
for (const key of CLAUDE_IDENTITY_HEADERS) {
|
||||
if (headers[key] !== undefined && headers[key] !== null) {
|
||||
captured[key] = headers[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(captured).length > 0) {
|
||||
cachedHeaders = captured;
|
||||
console.log(`[ClaudeHeaders] Cached ${Object.keys(captured).length} identity headers from Claude Code client`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recently cached Claude Code identity headers.
|
||||
* Returns null if no authentic client request has been seen yet (cold start).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getCachedClaudeHeaders() {
|
||||
return cachedHeaders;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function detectClientTool(headers = {}, body = {}) {
|
||||
const xApp = (headers["x-app"] || "").toLowerCase();
|
||||
const openaiIntent = (headers["openai-intent"] || "").toLowerCase();
|
||||
const initiator = (headers["x-initiator"] || headers["X-Initiator"] || "").toLowerCase();
|
||||
const originator = (headers["originator"] || "").toLowerCase();
|
||||
|
||||
// Antigravity: detected via body field (not header)
|
||||
if (body.userAgent === "antigravity") return "antigravity";
|
||||
@@ -37,8 +38,10 @@ export function detectClientTool(headers = {}, body = {}) {
|
||||
// Gemini CLI
|
||||
if (ua.includes("gemini-cli")) return "gemini-cli";
|
||||
|
||||
// Codex CLI
|
||||
if (ua.includes("codex-cli")) return "codex";
|
||||
// Codex CLI/Desktop — codex-tui is the current Rust CLI, codex-cli/codex_cli_rs legacy;
|
||||
// Codex Desktop identifies via UA "Codex Desktop" or originator "codex_work_desktop"
|
||||
if (ua.includes("codex-tui") || ua.includes("codex-cli") || ua.includes("codex_cli_rs") ||
|
||||
ua.includes("codex desktop") || originator.startsWith("codex_")) return "codex";
|
||||
|
||||
// DeepSeek TUI
|
||||
if (ua.includes("deepseek-tui")) return "deepseek-tui";
|
||||
|
||||
@@ -128,7 +128,8 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr
|
||||
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
||||
"x-client-key": clientKey,
|
||||
"x-cursor-checksum": checksum,
|
||||
"x-cursor-client-version": "3.1.0",
|
||||
"x-cursor-client-version": "3.12.17",
|
||||
"x-cursor-client-commit": "0fb762053c34788bb7760d5673f8a6d4c8589d50",
|
||||
"x-cursor-client-type": "ide",
|
||||
"x-cursor-client-os": os,
|
||||
"x-cursor-client-arch": arch,
|
||||
|
||||
144
open-sse/utils/kiroSessionReplay.js
Normal file
144
open-sse/utils/kiroSessionReplay.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
|
||||
|
||||
const sessionStartStore = new Map();
|
||||
const MAX_SESSION_STARTS = 5000;
|
||||
|
||||
function clone(value) {
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function sessionKey(connectionId, conversationId) {
|
||||
return `${connectionId || ""}:${conversationId || ""}`;
|
||||
}
|
||||
|
||||
function ensureUserMessageModelId(message, modelId) {
|
||||
if (message?.userInputMessage && !message.userInputMessage.modelId && modelId) {
|
||||
message.userInputMessage.modelId = modelId;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function ensureHistoryModelIds(history, modelId) {
|
||||
for (const item of history || []) {
|
||||
ensureUserMessageModelId(item, modelId);
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
function prefixUserMessage(message, contentPrefix, modelId) {
|
||||
const out = clone(message) || { userInputMessage: { content: "" } };
|
||||
if (!out.userInputMessage) out.userInputMessage = { content: "" };
|
||||
ensureUserMessageModelId(out, modelId);
|
||||
if (contentPrefix) {
|
||||
const content = out.userInputMessage.content || "";
|
||||
out.userInputMessage.content = content
|
||||
? `${contentPrefix}\n\n${content}`
|
||||
: contentPrefix;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findFirstUserIndex(history) {
|
||||
return history.findIndex((item) => item?.userInputMessage);
|
||||
}
|
||||
|
||||
function hasToolResults(message) {
|
||||
return !!message?.userInputMessage?.userInputMessageContext?.toolResults?.length;
|
||||
}
|
||||
|
||||
function canReplaceSessionStart(history, firstUserIndex) {
|
||||
return firstUserIndex === 0 && !hasToolResults(history[firstUserIndex]);
|
||||
}
|
||||
|
||||
function rememberSessionStart(key, entry) {
|
||||
if (sessionStartStore.size >= MAX_SESSION_STARTS) {
|
||||
sessionStartStore.delete(sessionStartStore.keys().next().value);
|
||||
}
|
||||
sessionStartStore.set(key, { ...entry, lastUsed: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve Kiro cacheability by freezing the first user message (`msg0`) for a
|
||||
* session, replaying that exact message as the first history user on later
|
||||
* turns, and injecting volatile current-time context only into the current turn.
|
||||
*/
|
||||
export function applyKiroSessionReplay({
|
||||
conversationId,
|
||||
connectionId,
|
||||
modelId,
|
||||
systemPrompt = "",
|
||||
contentPrefix = "",
|
||||
currentContentPrefix = "",
|
||||
history = [],
|
||||
currentMessage,
|
||||
} = {}) {
|
||||
const key = sessionKey(connectionId, conversationId);
|
||||
const existing = conversationId ? sessionStartStore.get(key) : null;
|
||||
const baseHistory = clone(history) || [];
|
||||
const baseCurrent = clone(currentMessage) || { userInputMessage: { content: "" } };
|
||||
|
||||
if (existing && existing.modelId === modelId && existing.systemPrompt === systemPrompt) {
|
||||
existing.lastUsed = Date.now();
|
||||
const firstUserIndex = findFirstUserIndex(baseHistory);
|
||||
const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId);
|
||||
if (canReplaceSessionStart(baseHistory, firstUserIndex)) {
|
||||
baseHistory[firstUserIndex] = sessionStart;
|
||||
} else {
|
||||
baseHistory.unshift(sessionStart);
|
||||
if (baseHistory.length === 1) {
|
||||
baseHistory.push({ assistantResponseMessage: { content: "..." } });
|
||||
}
|
||||
}
|
||||
return {
|
||||
history: ensureHistoryModelIds(baseHistory, modelId),
|
||||
currentMessage: prefixUserMessage(baseCurrent, currentContentPrefix, modelId),
|
||||
replayed: true,
|
||||
};
|
||||
}
|
||||
|
||||
const firstUserIndex = findFirstUserIndex(baseHistory);
|
||||
let sessionStart;
|
||||
let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId);
|
||||
if (canReplaceSessionStart(baseHistory, firstUserIndex)) {
|
||||
sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId);
|
||||
baseHistory[firstUserIndex] = clone(sessionStart);
|
||||
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
|
||||
} else if (firstUserIndex >= 0) {
|
||||
sessionStart = prefixUserMessage(
|
||||
{ userInputMessage: { content: "", modelId } },
|
||||
contentPrefix,
|
||||
modelId
|
||||
);
|
||||
baseHistory.unshift(clone(sessionStart));
|
||||
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
|
||||
} else {
|
||||
sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId);
|
||||
nextCurrent = clone(sessionStart);
|
||||
}
|
||||
|
||||
if (conversationId) {
|
||||
rememberSessionStart(key, {
|
||||
sessionStart: clone(sessionStart),
|
||||
modelId,
|
||||
systemPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
history: ensureHistoryModelIds(baseHistory, modelId),
|
||||
currentMessage: nextCurrent,
|
||||
replayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearKiroSessionReplayStore() {
|
||||
sessionStartStore.clear();
|
||||
}
|
||||
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of sessionStartStore) {
|
||||
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) sessionStartStore.delete(key);
|
||||
}
|
||||
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
|
||||
if (cleanup.unref) cleanup.unref();
|
||||
@@ -13,6 +13,7 @@ import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
|
||||
|
||||
// Runtime storage: Key = connectionId, Value = { sessionId, lastUsed }
|
||||
const runtimeSessionStore = new Map();
|
||||
const continuationStore = new Map();
|
||||
|
||||
// Periodically evict entries that haven't been used within TTL
|
||||
const cleanupInterval = setInterval(() => {
|
||||
@@ -80,6 +81,7 @@ export function generateBinaryStyleId() {
|
||||
export function clearSessionStore() {
|
||||
runtimeSessionStore.clear();
|
||||
assistantSessionStore.clear();
|
||||
continuationStore.clear();
|
||||
}
|
||||
|
||||
// Conversation-stable session store: Key = hash(scope+assistant text), Value = { sessionId, lastUsed }
|
||||
@@ -87,9 +89,10 @@ const assistantSessionStore = new Map();
|
||||
const ASSISTANT_MIN_LEN = 50;
|
||||
const ASSISTANT_CAP_LEN = 50;
|
||||
const MAX_ASSISTANT_SESSIONS = 5000;
|
||||
const MAX_CONTINUATION_SESSIONS = 5000;
|
||||
|
||||
// Client headers/body fields that carry an upstream session id (priority order)
|
||||
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id", "x-client-request-id"];
|
||||
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id"];
|
||||
const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/;
|
||||
|
||||
function sha16(text) {
|
||||
@@ -131,7 +134,7 @@ function extractAntigravitySession(body) {
|
||||
return m ? normalizeSessionId(m[1]) : null;
|
||||
}
|
||||
|
||||
function extractClientSessionId(headers, body) {
|
||||
function extractClientSessionId(headers, body, scope = "") {
|
||||
const claude = extractClaudeCodeSession(body?.metadata?.user_id);
|
||||
if (claude) return `claude:${claude}`;
|
||||
const antigravity = extractAntigravitySession(body);
|
||||
@@ -140,18 +143,25 @@ function extractClientSessionId(headers, body) {
|
||||
const v = headerValue(headers, key);
|
||||
if (v) return v;
|
||||
}
|
||||
const requestId = scope === "kiro" ? null : headerValue(headers, "x-client-request-id");
|
||||
if (requestId) return requestId;
|
||||
const fromBody =
|
||||
normalizeSessionId(body?.prompt_cache_key) ||
|
||||
normalizeSessionId(body?.session_id) ||
|
||||
normalizeSessionId(body?.conversation_id) ||
|
||||
normalizeSessionId(body?.metadata?.user_id);
|
||||
(scope === "kiro" ? null : normalizeSessionId(body?.metadata?.user_id));
|
||||
return fromBody || null;
|
||||
}
|
||||
|
||||
function requestMessages(body) {
|
||||
if (Array.isArray(body?.messages)) return body.messages;
|
||||
if (Array.isArray(body?.input)) return body.input;
|
||||
return [];
|
||||
}
|
||||
|
||||
// Accumulate assistant text from OpenAI/Responses-style input/messages (cap-limited)
|
||||
function accumulateAssistantText(body) {
|
||||
const items = Array.isArray(body?.input) ? body.input
|
||||
: Array.isArray(body?.messages) ? body.messages : null;
|
||||
const items = requestMessages(body);
|
||||
if (!items) return "";
|
||||
let text = "";
|
||||
for (const item of items) {
|
||||
@@ -193,16 +203,39 @@ function assistantTextSessionId(scope, body) {
|
||||
* @param {string} [opts.connectionId] - Connection identifier (fallback scope)
|
||||
* @param {string} [opts.workspaceId] - Provider workspace id (account-wide fallback)
|
||||
* @param {string} [opts.scope] - Provider scope to isolate cache keys across providers
|
||||
* @returns {string} A stable session id
|
||||
* @returns {{sessionId: string, ephemeral: boolean}} A session id plus whether it is one-shot
|
||||
*/
|
||||
export function resolveSessionId({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
|
||||
const client = extractClientSessionId(headers, body);
|
||||
if (client) return client;
|
||||
const fromAssistant = assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
|
||||
if (fromAssistant) return fromAssistant;
|
||||
export function resolveSessionIdentity({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
|
||||
const client = extractClientSessionId(headers, body, scope);
|
||||
if (client) return { sessionId: client, ephemeral: false };
|
||||
const fromAssistant = scope === "kiro" ? null : assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
|
||||
if (fromAssistant) return { sessionId: fromAssistant, ephemeral: false };
|
||||
const ws = normalizeSessionId(workspaceId);
|
||||
if (ws) return ws;
|
||||
return deriveSessionId(connectionId);
|
||||
if (ws) return { sessionId: ws, ephemeral: false };
|
||||
if (scope === "kiro") return { sessionId: generateBinaryStyleId(), ephemeral: true };
|
||||
return { sessionId: deriveSessionId(connectionId), ephemeral: false };
|
||||
}
|
||||
|
||||
export function resolveSessionId(opts = {}) {
|
||||
return resolveSessionIdentity(opts).sessionId;
|
||||
}
|
||||
|
||||
export function resolveContinuationId({ sessionId, connectionId, scope = "", ephemeral = false } = {}) {
|
||||
if (ephemeral) return crypto.randomUUID();
|
||||
const key = `${scope}:${connectionId || ""}:${sessionId || ""}`;
|
||||
const existing = continuationStore.get(key);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
continuationStore.delete(key);
|
||||
continuationStore.set(key, existing);
|
||||
return existing.continuationId;
|
||||
}
|
||||
const continuationId = crypto.randomUUID();
|
||||
if (continuationStore.size >= MAX_CONTINUATION_SESSIONS) {
|
||||
continuationStore.delete(continuationStore.keys().next().value);
|
||||
}
|
||||
continuationStore.set(key, { continuationId, lastUsed: Date.now() });
|
||||
return continuationId;
|
||||
}
|
||||
|
||||
// Capture session id from request body + credentials (envelope still intact here)
|
||||
@@ -227,5 +260,8 @@ const assistantCleanup = setInterval(() => {
|
||||
for (const [key, entry] of assistantSessionStore) {
|
||||
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) assistantSessionStore.delete(key);
|
||||
}
|
||||
for (const [key, entry] of continuationStore) {
|
||||
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) continuationStore.delete(key);
|
||||
}
|
||||
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
|
||||
if (assistantCleanup.unref) assistantCleanup.unref();
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { translateResponse, initState } from "../translator/index.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
|
||||
<<<<<<< HEAD
|
||||
import { extractUsage, hasValidUsage, estimateUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
|
||||
import { saveUsageStats } from "../handlers/chatCore/requestDetail.js";
|
||||
=======
|
||||
import { extractUsage, mergeUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
|
||||
>>>>>>> 7f436e2792be4fa5a4d1c4d6b8e9bc85eaaa6a3d
|
||||
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js";
|
||||
import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js";
|
||||
import { dbg, isDebugEnabled } from "./debugLog.js";
|
||||
@@ -49,6 +44,7 @@ export function createSSEStream(options = {}) {
|
||||
provider = null,
|
||||
reqLogger = null,
|
||||
toolNameMap = null,
|
||||
customToolNames = null,
|
||||
model = null,
|
||||
connectionId = null,
|
||||
body = null,
|
||||
@@ -62,7 +58,9 @@ export function createSSEStream(options = {}) {
|
||||
// Per-stream decoder with stream:true to correctly handle multi-byte chars split across chunks
|
||||
const decoder = new TextDecoder("utf-8", { fatal: false });
|
||||
|
||||
const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null;
|
||||
const state = mode === STREAM_MODE.TRANSLATE
|
||||
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model }
|
||||
: null;
|
||||
|
||||
let totalContentLength = 0;
|
||||
let accumulatedContent = "";
|
||||
@@ -362,7 +360,7 @@ export function createSSEStream(options = {}) {
|
||||
}
|
||||
|
||||
if (hasValidUsage(usage)) {
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey });
|
||||
logUsage(provider, usage, model, connectionId, apiKey);
|
||||
} else {
|
||||
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
|
||||
}
|
||||
@@ -451,7 +449,7 @@ export function createSSEStream(options = {}) {
|
||||
}
|
||||
|
||||
if (hasValidUsage(state?.usage)) {
|
||||
saveUsageStats({ provider: state.provider || targetFormat, model, tokens: state.usage, connectionId, apiKey });
|
||||
logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey);
|
||||
} else {
|
||||
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
|
||||
}
|
||||
@@ -469,7 +467,7 @@ export function createSSEStream(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) {
|
||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null) {
|
||||
return createSSEStream({
|
||||
mode: STREAM_MODE.TRANSLATE,
|
||||
targetFormat,
|
||||
@@ -477,6 +475,7 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
|
||||
provider,
|
||||
reqLogger,
|
||||
toolNameMap,
|
||||
customToolNames,
|
||||
model,
|
||||
connectionId,
|
||||
body,
|
||||
|
||||
@@ -15,16 +15,19 @@ function getTimeString() {
|
||||
* @param {string} options.provider - Provider name
|
||||
* @param {string} options.model - Model name
|
||||
*/
|
||||
export function createStreamController({ onDisconnect, onError, log, provider, model } = {}) {
|
||||
export function createStreamController({ onDisconnect, onError, log, provider, model, reqTag = "" } = {}) {
|
||||
const abortController = new AbortController();
|
||||
const startTime = Date.now();
|
||||
let disconnected = false;
|
||||
let abortTimeout = null;
|
||||
|
||||
const logStream = (status) => {
|
||||
// Only abnormal terminations are logged; normal completion is covered by "📊 done".
|
||||
// isError uses errorLine (always shown, ignores LOG_LEVEL) so failures survive quiet levels.
|
||||
const logStream = (symbol, status, isError = false) => {
|
||||
const duration = Date.now() - startTime;
|
||||
const p = provider?.toUpperCase() || "UNKNOWN";
|
||||
console.log(`[${getTimeString()}] 🌊 [STREAM] ${p} | ${model || "unknown"} | ${duration}ms | ${status}`);
|
||||
const emit = isError ? log?.errorLine : log?.line;
|
||||
if (emit) emit(reqTag, symbol, `${status} · ${provider}/${model} · ${duration}ms`);
|
||||
else console.log(`[${getTimeString()}] ${symbol} ${provider}/${model} · ${status} · ${duration}ms`);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -38,7 +41,7 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
|
||||
if (disconnected) return;
|
||||
disconnected = true;
|
||||
|
||||
logStream(`disconnect: ${reason}`);
|
||||
logStream("⚡", `DISCONNECT: ${reason}`);
|
||||
dbg("CTRL", `${provider}/${model} | disconnect=${reason} | dur=${Date.now() - startTime}ms`);
|
||||
|
||||
// Delay abort to allow cleanup
|
||||
@@ -49,13 +52,11 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
|
||||
onDisconnect?.({ reason, duration: Date.now() - startTime });
|
||||
},
|
||||
|
||||
// Call when stream completes normally
|
||||
// Call when stream completes normally (no line here — "📊 done" is authoritative)
|
||||
handleComplete: () => {
|
||||
if (disconnected) return;
|
||||
disconnected = true;
|
||||
|
||||
logStream("complete");
|
||||
|
||||
if (abortTimeout) {
|
||||
clearTimeout(abortTimeout);
|
||||
abortTimeout = null;
|
||||
@@ -73,11 +74,11 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
|
||||
}
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
logStream("aborted");
|
||||
logStream("⚡", "ABORTED");
|
||||
return;
|
||||
}
|
||||
|
||||
logStream(`error: ${error.message}`);
|
||||
logStream("✗", `ERROR: ${error.message}${error.stack ? `\n ${error.stack}` : ""}`, true);
|
||||
onError?.(error);
|
||||
},
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
|
||||
// Legacy per-chunk usage console line; off by default (superseded by "📊 done")
|
||||
const DEBUG_USAGE = process.env.LOG_USAGE_VERBOSE === "1";
|
||||
|
||||
// ANSI color codes
|
||||
export const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
@@ -400,6 +403,10 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI
|
||||
export function logUsage(provider, usage, model = null, connectionId = null, apiKey = null) {
|
||||
if (!usage || typeof usage !== "object") return;
|
||||
|
||||
// Console output moved to the unified "📊 done" line (streamingHandler). Kept as
|
||||
// a no-op hook so callers stay unchanged; usage persistence happens via saveUsageStats.
|
||||
if (!DEBUG_USAGE) return;
|
||||
|
||||
const p = provider?.toUpperCase() || "UNKNOWN";
|
||||
|
||||
// Support both formats:
|
||||
|
||||
Reference in New Issue
Block a user