diff --git a/open-sse/services/reasoningCache.ts b/open-sse/services/reasoningCache.ts index 3d4a715fe0..e23a388d6a 100644 --- a/open-sse/services/reasoningCache.ts +++ b/open-sse/services/reasoningCache.ts @@ -22,6 +22,7 @@ import { getReasoningCacheStats, setReasoningCache, } from "../../src/lib/db/reasoningCache.ts"; +import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts"; // ──────────────── Provider/Model Detection ──────────────── @@ -194,6 +195,9 @@ export function cacheReasoningByKey( reasoning: string ): void { if (!key || !reasoning) return; + // ponytail: never store the internal replay placeholder — models echo it + // and it poisons the cache (upstream echo loop, OmniRoute #9573). + if (isInternalReasoningPlaceholder(reasoning)) return; if (reasoning.length > MAX_ENTRY_BYTES) { reasoning = reasoning.slice(0, MAX_ENTRY_BYTES); @@ -259,6 +263,8 @@ export function cacheReasoningFromAssistantMessage( ? message.reasoning : ""; if (!reasoning) return 0; + // ponytail: don't capture the echoed placeholder into the cache. + if (isInternalReasoningPlaceholder(reasoning)) return 0; const toolCallIds = Array.isArray(message.tool_calls) ? (message.tool_calls as ToolCallLike[]) @@ -299,6 +305,12 @@ export function lookupReasoning(toolCallId: string): string | null { const mem = memoryCache.get(toolCallId); if (mem) { if (Date.now() < mem.expiresAt) { + // ponytail: never replay the internal placeholder from memory. + if (isInternalReasoningPlaceholder(mem.reasoning)) { + memoryCache.delete(toolCallId); + misses++; + return null; + } hits++; return mem.reasoning; } @@ -314,6 +326,11 @@ export function lookupReasoning(toolCallId: string): string | null { // DB lookup failure is non-fatal; treat it as a cache miss. } if (dbResult) { + // ponytail: never promote/replay the internal placeholder from DB. + if (isInternalReasoningPlaceholder(dbResult.reasoning)) { + misses++; + return null; + } hits++; let promotedReasoning = dbResult.reasoning; if (promotedReasoning.length > MAX_ENTRY_BYTES) { diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 9484641da2..3e0c4a5a3c 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -14,6 +14,7 @@ import { resolveConnectionCacheOverride, } from "../utils/cacheControlPolicy.ts"; import { requiresAuthenticReasoningContent } from "../utils/reasoningContentInjector.ts"; +import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts"; import { coerceToolSchemas, injectEmptyReasoningContentForToolCalls, @@ -483,10 +484,11 @@ export function translateRequest( !hasNonEmptyReasoningContent(msg); if (!hasToolCalls && !hasToolUseBlocks && !shouldReplayReasoningOnly) { - // Strip empty reasoning_content on non-tool-call messages we are NOT - // replaying (e.g. non-DeepSeek targets); an empty string has no meaningful - // value to send and may confuse some upstreams. - if (msg.reasoning_content === "") { + // Strip empty or placeholder reasoning_content on non-tool-call messages + // we are NOT replaying. The placeholder is request scaffolding, never + // real reasoning — forwarding it makes the model continue its chain of + // thought FROM that text (echo → empty stop, #9573). + if (msg.reasoning_content === "" || isInternalReasoningPlaceholder(msg.reasoning_content)) { delete msg.reasoning_content; } continue; @@ -528,9 +530,17 @@ export function translateRequest( } // ── OpenAI-format message ── - // Skip if client already provided real reasoning_content + // Skip if client already provided real reasoning_content. The internal + // replay placeholder is NOT real reasoning: drop it and fall through to + // the cache lookup so it can be replaced with genuine cached reasoning. + // Forwarding it makes the model continue its chain of thought from that + // text (echo → empty stop), and the echo re-poisons cache + client + // history (#9573). if (hasNonEmptyReasoningContent(msg)) { - continue; + if (!isInternalReasoningPlaceholder(msg.reasoning_content)) { + continue; + } + delete msg.reasoning_content; } const cacheKey = hasToolCalls @@ -553,19 +563,17 @@ export function translateRequest( continue; } - // Cache miss fallback — use a non-empty placeholder. - // Empty string causes DeepSeek V4+ to reject with 400: - // "reasoning_content in the thinking mode must be passed back to the API." - // Note: injectEmptyReasoningContentForToolCalls may have pre-set - // reasoning_content="" before the cache lookup, so we check for - // both undefined AND empty string here. - // - // Applies to tool-call messages AND to plain (non-tool-call) assistant turns - // on DeepSeek replay targets (#1682). Without the placeholder on plain turns, - // a multi-turn text conversation whose reasoning_content the client stripped - // is forwarded to DeepSeek without the field and rejected with 400. + // Cache miss fallback — previously injected a non-empty placeholder + // (NON_ANTHROPIC_THINKING_PLACEHOLDER) to dodge an alleged DeepSeek V4 400 + // on missing reasoning_content. The placeholder is the root cause of this + // bug: the model echoes it as its own reasoning and stops (empty turns), + // and the echo re-poisons the cache + client history (#9573). Empirically, + // deepseek-v4-flash accepts an ABSENT reasoning_content field (the 400 is + // specific to empty-string, and even that is endpoint-dependent). Omit + // the field instead; providers that genuinely enforce the contract + // (kimi-coding, moonshot authentic-reasoning) have their own paths above. if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) { - msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; + delete msg.reasoning_content; } } } else if ( diff --git a/open-sse/utils/reasoningFields.ts b/open-sse/utils/reasoningFields.ts index e0d045e10b..a8b858e25e 100644 --- a/open-sse/utils/reasoningFields.ts +++ b/open-sse/utils/reasoningFields.ts @@ -1,3 +1,5 @@ +import { stripInternalReasoningPlaceholder } from "./reasoningPlaceholder.ts"; + type JsonRecord = Record; export function asReasoningRecord(value: unknown): JsonRecord { @@ -69,4 +71,17 @@ export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target: const mirrored = getUnsupportedReasoningValue(source); if (mirrored) target.reasoning_content = mirrored; } + // ponytail: the internal replay placeholder is request scaffolding, never + // real reasoning — models echo it and it poisons client history + the cache + // (#8081 echo). Strip it from anything we forward to the client. + if (typeof target.reasoning_content === "string") { + const stripped = stripInternalReasoningPlaceholder(target.reasoning_content); + if (stripped === "") delete target.reasoning_content; + else if (stripped !== target.reasoning_content) target.reasoning_content = stripped; + } + if (typeof target.reasoning === "string") { + const stripped = stripInternalReasoningPlaceholder(target.reasoning); + if (stripped === "") delete target.reasoning; + else if (stripped !== target.reasoning) target.reasoning = stripped; + } } diff --git a/src/lib/db/reasoningCache.ts b/src/lib/db/reasoningCache.ts index d7f4b69e79..ae36c0bb9b 100644 --- a/src/lib/db/reasoningCache.ts +++ b/src/lib/db/reasoningCache.ts @@ -81,6 +81,8 @@ export function setReasoningCache( reasoning: string, ttlMs: number = DEFAULT_TTL_MS ): void { + const PLACEHOLDER = "(prior reasoning summary unavailable)"; + if (!reasoning || reasoning.trim() === PLACEHOLDER) return; // ponytail: never store the internal placeholder if (reasoning.length > MAX_ENTRY_BYTES) { reasoning = reasoning.slice(0, MAX_ENTRY_BYTES); } @@ -110,6 +112,8 @@ export function getReasoningCache( ) .get(toolCallId) as { reasoning: string; provider: string; model: string } | undefined; + const PLACEHOLDER = "(prior reasoning summary unavailable)"; + if (row && row.reasoning && row.reasoning.trim() === PLACEHOLDER) return null; // ponytail: never replay the placeholder return row ?? null; }