merge origin/master into gitea/new_feature
Bring local branch up to v0.5.35 while keeping xAI image/edit, SuperGrok quota tracking, per-provider timeouts, and pinned model-test actions.
This commit is contained in:
125
open-sse/utils/kiroSessionReplay.js
Normal file
125
open-sse/utils/kiroSessionReplay.js
Normal file
@@ -0,0 +1,125 @@
|
||||
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 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 (firstUserIndex >= 0) {
|
||||
baseHistory[firstUserIndex] = sessionStart;
|
||||
} else {
|
||||
baseHistory.unshift(sessionStart);
|
||||
}
|
||||
return {
|
||||
history: ensureHistoryModelIds(baseHistory, modelId),
|
||||
currentMessage: prefixUserMessage(baseCurrent, currentContentPrefix, modelId),
|
||||
replayed: true,
|
||||
};
|
||||
}
|
||||
|
||||
const firstUserIndex = findFirstUserIndex(baseHistory);
|
||||
let sessionStart;
|
||||
let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId);
|
||||
if (firstUserIndex >= 0) {
|
||||
sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId);
|
||||
baseHistory[firstUserIndex] = 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();
|
||||
|
||||
@@ -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",
|
||||
@@ -401,6 +404,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