Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions open-sse/handlers/chatCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
import { captureSessionId, extractClientSessionId, getRecentThoughts, saveRecentThought } from "../utils/sessionManager.js";
import { injectContinuity } from "../rtk/continuity.js";
import { dedupeTools } from "../utils/toolDeduper.js";
import { injectCaveman } from "../rtk/caveman.js";
import { injectPonytail } from "../rtk/ponytail.js";
Expand All @@ -34,11 +36,18 @@ import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
* @param {object} options.credentials - Provider credentials
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
*/
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking, continuityEnabled, continuityCount }) {
const { provider, model } = modelInfo;
const requestStartTime = Date.now();

const sourceFormat = sourceFormatOverride || detectFormat(body);
// Continuity: session ID must be provider-independent so combo switching shares the same thought buffer.
// Use apiKey as scope (stable per client across all combo providers) instead of connectionId (provider-specific).
const continuitySessionId = captureSessionId(body, credentials, apiKey);
// Thought buffer key: decoupled from session ID (which changes on context summarization
// because assistantTextSessionId hashes message content that shifts on truncation/summary).
// Uses client-provided session ID when available (stable across summarization), falls back to apiKey.
const thoughtKey = extractClientSessionId(credentials?.rawHeaders, body) || apiKey || continuitySessionId;

// Check for bypass patterns (warmup, skip, cc naming)
const bypassResponse = handleBypassRequest(body, model, userAgent, ccFilterNaming);
Expand Down Expand Up @@ -185,6 +194,15 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
log?.debug?.("PONYTAIL", `${ponytailLevel} | ${finalFormat}`);
}

// Continuity: inject framed recent thoughts into system prompt
if (continuityEnabled) {
const recentThoughts = getRecentThoughts(thoughtKey, continuityCount);
if (recentThoughts && recentThoughts.length > 0) {
injectContinuity(translatedBody, finalFormat, recentThoughts);
log?.debug?.("CONTINUITY", `injected ${recentThoughts.length} thoughts into system | ${finalFormat}`);
}
}

const executor = getExecutor(provider);
trackPendingRequest(model, provider, connectionId, true);
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });
Expand Down Expand Up @@ -308,7 +326,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
return createErrorResult(statusCode, errMsg, resetsAtMs);
}

const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess };
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, continuityEnabled, saveThinking: continuityEnabled ? (thinking) => { if (thinking) saveRecentThought(thoughtKey, thinking); } : null };
const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { });
const trackDone = () => trackPendingRequest(model, provider, connectionId, false);

Expand All @@ -326,8 +344,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}

// Streaming response
const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx });
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete });
const { onStreamComplete: _origOnStreamComplete } = buildOnStreamComplete({ ...sharedCtx });
const onStreamComplete = (contentObj, usage, ttftAt) => {
if (contentObj?.thinking && sharedCtx.saveThinking) {
sharedCtx.saveThinking(contentObj.thinking);
}
return _origOnStreamComplete(contentObj, usage, ttftAt);
};
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, continuityEnabled });
}

export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
Expand Down
14 changes: 13 additions & 1 deletion open-sse/handlers/chatCore/nonStreamingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
import { createErrorResult } from "../../utils/error.js";
import { extractThinking } from "../../utils/thinkingExtractor.js";
import { stripTaggedThinking } from "../../utils/taggedThinkingNormalizer.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js";
Expand Down Expand Up @@ -198,7 +200,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
/**
* Handle non-streaming response from provider.
*/
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog }) {
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, saveThinking, continuityEnabled }) {
trackDone();
const contentType = providerResponse.headers.get("content-type") || "";
let responseBody;
Expand Down Expand Up @@ -258,6 +260,16 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
}

// Strip content-embedded thinking tags (keep inner text as visible content) and extract thinking (continuity)
if (continuityEnabled) {
stripTaggedThinking(translatedResponse);

const extractedThinking = extractThinking(translatedResponse);
if (extractedThinking && saveThinking) {
saveThinking(extractedThinking);
}
}

// Strip Azure-specific fields
if (!isClaudeMessageResponse) {
delete translatedResponse.prompt_filter_results;
Expand Down
24 changes: 22 additions & 2 deletions open-sse/handlers/chatCore/sseToJsonHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js";
import { PROVIDERS } from "../../config/providers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
import { stripTaggedThinking, stripTags } from "../../utils/taggedThinkingNormalizer.js";
import { extractThinking } from "../../utils/thinkingExtractor.js";

// Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape
const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
Expand Down Expand Up @@ -102,7 +104,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
* Handle case: provider forced streaming but client wants JSON.
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
*/
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog }) {
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog, continuityEnabled, saveThinking }) {
const contentType = providerResponse.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
if (!isSSE) return null; // not handled here
Expand All @@ -126,7 +128,12 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });

const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
const { msgItem, textContent: rawTextContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
let textContent = rawTextContent;
if (continuityEnabled) {
const stripped = stripTags(textContent);
if (stripped !== null) textContent = stripped;
}
const totalLatency = Date.now() - requestStartTime;

saveRequestDetail(buildRequestDetail({
Expand Down Expand Up @@ -183,6 +190,12 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
};
}

// Continuity: extract thinking for session buffer
if (continuityEnabled) {
const thinkingForBuffer = extractThinking(jsonResponse);
if (thinkingForBuffer && saveThinking) saveThinking(thinkingForBuffer);
}

return { success: true, response: new Response(JSON.stringify(finalResp), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
} catch (err) {
console.error("[ChatCore] Responses API SSE→JSON failed:", err);
Expand Down Expand Up @@ -215,6 +228,13 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
status: "success"
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {});

// Continuity: strip tagged thinking + extract thinking for session buffer
if (continuityEnabled) {
stripTaggedThinking(parsed);
const extractedThinking = extractThinking(parsed);
if (extractedThinking && saveThinking) saveThinking(extractedThinking);
}

// Strip reasoning_content only when content is non-empty.
// When content is empty (e.g. thinking models that used all tokens for reasoning),
// reasoning_content is the only useful output and must be preserved.
Expand Down
12 changes: 6 additions & 6 deletions open-sse/handlers/chatCore/streamingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,28 @@ const CODEX_SOURCE_TO_TARGET = {
/**
* Determine which SSE transform stream to use based on provider/format.
*/
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) {
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, continuityEnabled }) {
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
const needsCodexTranslation = isResponsesProvider && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI;

if (needsCodexTranslation) {
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, continuityEnabled);
}

if (needsTranslation(targetFormat, sourceFormat)) {
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, continuityEnabled);
}

return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey, continuityEnabled);
}

/**
* Handle streaming response — pipe provider SSE through transform stream to client.
*/
export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) {
export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, continuityEnabled }) {
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
Expand All @@ -60,7 +60,7 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou
console.warn('[STREAM] ' + provider + ' | ' + model + ' | unexpected Content-Type: ' + upstreamContentType);
}

const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey });
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, continuityEnabled });

// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
Expand Down
8 changes: 8 additions & 0 deletions open-sse/rtk/continuity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { injectSystemPrompt } from "./systemInject.js";
import { buildContinuityPrompt } from "./continuityPrompt.js";

export function injectContinuity(body, format, recentThoughts) {
const framedContext = buildContinuityPrompt(recentThoughts);
if (!framedContext) return;
injectSystemPrompt(body, format, framedContext);
}
32 changes: 32 additions & 0 deletions open-sse/rtk/continuityPrompt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
function maxBacktickRun(text) {
let max = 0, current = 0;
for (const ch of text) {
if (ch === '`') { current++; max = Math.max(max, current); }
else { current = 0; }
}
return max;
}

export function buildContinuityPrompt(recentThoughts) {
if (!recentThoughts || recentThoughts.length === 0) return null;
const maxRun = recentThoughts.reduce((m, t) => Math.max(m, maxBacktickRun(t)), 0);
const fenceLen = Math.max(5, maxRun + 1);
const fence = '`'.repeat(fenceLen);
return [
"[HOST CONTINUATION CHECKPOINT]",
"",
"<continuation_checkpoint>",
"The block below is private continuation context from your immediately preceding turns.",
"It is the causal context behind the prior visible conversation history.",
"Use it to understand why that history unfolded as it did, then continue from the active user input below.",
"",
fence + "text",
recentThoughts.join("\n\n---\n\n"),
fence,
"",
"</continuation_checkpoint>",
"",
"[HOST RESUME]",
"Continuity restored. Continue from the active input below, using the checkpoint as context for the prior visible history."
].join("\n");
}
Loading