fix(backend): stop reasoning replay placeholder from self-poisoning (#9573) (#9610)

Merge-train validated (tip 6ce4effef8). Vitest failures confirmed as base-red (#9679).
This commit is contained in:
stanley
2026-08-08 06:54:23 +07:00
committed by GitHub
parent 8cb51b7922
commit 552f2e1563
4 changed files with 62 additions and 18 deletions

View File

@@ -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) {

View File

@@ -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 (

View File

@@ -1,3 +1,5 @@
import { stripInternalReasoningPlaceholder } from "./reasoningPlaceholder.ts";
type JsonRecord = Record<string, unknown>;
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;
}
}

View File

@@ -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;
}