From 18ef28ea771b52b54071e923b0752cf14688be47 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 14 May 2026 09:20:21 -0300 Subject: [PATCH] fix(deepseek): preserve reasoning_content for DeepSeek V4 models (cherry-pick from PR #2231) Cherry-picks non-overlapping changes from @kang-heewon's PR #2231: - isDeepSeekV4Model() check in responseSanitizer - providerRegistry V4 model entries with supportsReasoning - schemaCoercion model-param for injectEmptyReasoningContentForToolCalls - reasoningCache request-ID-based stable keys - translator reasoning-only message replay for DeepSeek - Comprehensive test coverage (81 tests across 5 providers) Co-authored-by: kang-heewon --- open-sse/config/providerRegistry.ts | 42 +++-- open-sse/handlers/responseSanitizer.ts | 18 ++- open-sse/services/reasoningCache.ts | 83 ++++++++-- open-sse/translator/helpers/schemaCoercion.ts | 14 +- open-sse/translator/index.ts | 98 +++++++++--- tests/unit/reasoning-cache.test.ts | 147 ++++++++++++++++-- tests/unit/response-sanitizer.test.ts | 55 +++++++ tests/unit/schema-coercion.test.ts | 30 ++++ tests/unit/tool-request-sanitization.test.ts | 10 +- tests/unit/translator-helper-branches.test.ts | 74 ++++++++- 10 files changed, 495 insertions(+), 76 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index baa19cd1ba..358ae98b10 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1056,8 +1056,8 @@ export const REGISTRY: Record = { { id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus" }, { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, ], }, @@ -1313,8 +1313,8 @@ export const REGISTRY: Record = { { id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, { id: "google/gemini-3-flash-preview", name: "Gemini 3 Flash" }, { id: "google/gemini-3.1-flash-lite", name: "Gemini 3.1 Flash Lite" }, - { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, - { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" }, ], passthroughModels: true, @@ -1345,8 +1345,8 @@ export const REGISTRY: Record = { { id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, { id: "google/gemini-3-flash-preview", name: "Gemini 3 Flash" }, { id: "openai/gpt-5.5", name: "GPT-5.5" }, - { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, - { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, ], passthroughModels: true, }, @@ -1625,7 +1625,7 @@ export const REGISTRY: Record = { // Seed list — runtime /v1/models discovery keeps this fresh. // Source: GET https://crof.ai/v1/models (2026-04-25). models: [ - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, { id: "deepseek-v3.2", name: "DeepSeek V3.2" }, { id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "kimi-k2.6-precision", name: "Kimi K2.6 (Precision)" }, @@ -1694,8 +1694,8 @@ export const REGISTRY: Record = { authType: "apikey", authHeader: "bearer", models: [ - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, ], }, @@ -1994,7 +1994,11 @@ export const REGISTRY: Record = { { id: "accounts/fireworks/models/minimax-m2p7", name: "MiniMax M2.7" }, { id: "accounts/fireworks/models/qwen3p6-plus", name: "Qwen3.6 Plus" }, { id: "accounts/fireworks/models/glm-5p1", name: "GLM 5.1" }, - { id: "accounts/fireworks/models/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { + id: "accounts/fireworks/models/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + supportsReasoning: true, + }, ], }, @@ -2024,8 +2028,8 @@ export const REGISTRY: Record = { // Note: rate limits vary by plan (free = "Light usage", Pro = more, Max = 5x Pro). // Users can generate API keys at https://ollama.com/settings/api-keys models: [ - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, { id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "minimax-m2.7", name: "MiniMax M2.7" }, @@ -2070,7 +2074,7 @@ export const REGISTRY: Record = { { id: "qwen/qwen3.5-397b-a17b", name: "Qwen3.5-397B-A17B" }, { id: "qwen/qwen3.5-122b-a10b", name: "Qwen3.5-122B-A10B" }, { id: "stepfun-ai/step-3.5-flash", name: "Step 3.5 Flash" }, - { id: "deepseek-ai/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "deepseek-ai/deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false }, { id: "openai/gpt-oss-20b", name: "GPT OSS 20B", toolCalling: false }, { id: "nvidia/nemotron-3-super-120b-a12b", name: "Nemotron 3 Super 120B A12B" }, @@ -2353,8 +2357,16 @@ export const REGISTRY: Record = { { id: "google/gemini-3-flash", name: "Gemini 3 Flash (Puter)" }, { id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro (Puter)" }, // DeepSeek — use deepseek/ prefix (confirmed working) - { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro (Puter)" }, - { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash (Puter)" }, + { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro (Puter)", + supportsReasoning: true, + }, + { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash (Puter)", + supportsReasoning: true, + }, // xAI Grok — use x-ai/ prefix { id: "x-ai/grok-4.3", name: "Grok 4.3 (Puter)" }, { id: "x-ai/grok-4.20", name: "Grok 4.20 (Puter)" }, diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index cf3131f79e..dfd7bb7a85 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -27,6 +27,10 @@ const ALLOWED_RESPONSES_USAGE_FIELDS = new Set([ type JsonRecord = Record; +function isDeepSeekV4Model(model: unknown): boolean { + return typeof model === "string" && /^deepseek-v4(?:$|[-_/])/i.test(model); +} + function toRecord(value: unknown): JsonRecord | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; return value as JsonRecord; @@ -108,6 +112,7 @@ export function extractThinkingFromContent(text: string): { export function sanitizeOpenAIResponse(body: unknown): unknown { const bodyRecord = toRecord(body); if (!bodyRecord) return body; + const isDeepSeekV4 = isDeepSeekV4Model(bodyRecord.model); // Build sanitized response with only allowed top-level fields const sanitized: JsonRecord = {}; @@ -120,7 +125,9 @@ export function sanitizeOpenAIResponse(body: unknown): unknown { // Sanitize choices if (Array.isArray(bodyRecord.choices)) { - sanitized.choices = bodyRecord.choices.map((choice, idx) => sanitizeChoice(choice, idx)); + sanitized.choices = bodyRecord.choices.map((choice, idx) => + sanitizeChoice(choice, idx, isDeepSeekV4) + ); } else { sanitized.choices = []; } @@ -182,7 +189,7 @@ export function sanitizeResponsesApiResponse(body: unknown): unknown { /** * Sanitize a single choice object. */ -function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord { +function sanitizeChoice(choice: unknown, defaultIndex: number, isDeepSeekV4 = false): JsonRecord { const choiceRecord = toRecord(choice); const sanitized: JsonRecord = { index: defaultIndex, @@ -199,7 +206,7 @@ function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord { // Sanitize message (non-streaming) or delta (streaming) if (choiceRecord?.message !== undefined) { - sanitized.message = sanitizeMessage(choiceRecord.message); + sanitized.message = sanitizeMessage(choiceRecord.message, isDeepSeekV4); } if (choiceRecord?.delta !== undefined) { sanitized.delta = sanitizeMessage(choiceRecord.delta); @@ -216,7 +223,7 @@ function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord { /** * Sanitize a message object, extracting tags if present. */ -function sanitizeMessage(msg: unknown): unknown { +function sanitizeMessage(msg: unknown, isDeepSeekV4 = false): unknown { const msgRecord = toRecord(msg); if (!msgRecord) return msg; @@ -289,7 +296,8 @@ function sanitizeMessage(msg: unknown): unknown { sanitized.reasoning_content !== undefined && hasVisibleMessageContent(sanitized.content) && !msgRecord.tool_calls && - !msgRecord.function_call + !msgRecord.function_call && + !isDeepSeekV4 ) { delete sanitized.reasoning_content; } diff --git a/open-sse/services/reasoningCache.ts b/open-sse/services/reasoningCache.ts index 95da644d27..2bda7dab9f 100644 --- a/open-sse/services/reasoningCache.ts +++ b/open-sse/services/reasoningCache.ts @@ -45,6 +45,7 @@ const REASONING_REPLAY_MODEL_PATTERNS = [ /deepseek-r1/i, /deepseek-reasoner/i, /deepseek-chat/i, + /deepseek[-/]v4[-.](flash|pro)/i, /kimi-k2/i, /qwq/i, /qwen.*think/i, @@ -54,12 +55,29 @@ const REASONING_REPLAY_MODEL_PATTERNS = [ /^mimo[-.]?v\d/i, ]; +const DEEPSEEK_V4_MODEL_PATTERN = /deepseek[-/]v4[-.](flash|pro)/i; + +export function isDeepSeekReasoningModel(params: { + provider: string; + model: string; + thinkingEnabled?: boolean; +}): boolean { + if (params.thinkingEnabled !== true) return false; + return DEEPSEEK_V4_MODEL_PATTERN.test(params.model); +} + /** * Check if a provider/model combination requires reasoning replay. */ -export function requiresReasoningReplay(provider: string, model: string): boolean { - const normalizedProvider = provider.trim().toLowerCase(); - const normalizedModel = model.trim(); +export function requiresReasoningReplay(params: { + provider: string; + model: string; + thinkingEnabled?: boolean; + supportsReasoning?: boolean; +}): boolean { + if (isDeepSeekReasoningModel(params)) return true; + const normalizedProvider = params.provider.trim().toLowerCase(); + const normalizedModel = params.model.trim(); if (REASONING_REPLAY_PROVIDERS.has(normalizedProvider)) return true; return REASONING_REPLAY_MODEL_PATTERNS.some((p) => p.test(normalizedModel)); } @@ -81,6 +99,11 @@ type AssistantMessageLike = { reasoning?: unknown; }; +type AssistantMessageCacheContext = { + requestId?: string; + messageIndex?: number; +}; + type ToolCallLike = { id?: unknown; }; @@ -125,7 +148,7 @@ function purgeExpiredMemory(): void { } /** - * Cache a reasoning_content string for one or more tool_call IDs. + * Cache a reasoning_content string for one tool_call ID. * Writes to memory and best-effort DB persistence. */ export function cacheReasoning( @@ -134,7 +157,16 @@ export function cacheReasoning( model: string, reasoning: string ): void { - if (!toolCallId || !reasoning) return; + cacheReasoningByKey(toolCallId, provider, model, reasoning); +} + +export function cacheReasoningByKey( + key: string, + provider: string, + model: string, + reasoning: string +): void { + if (!key || !reasoning) return; const now = Date.now(); @@ -142,7 +174,7 @@ export function cacheReasoning( if (memoryCache.size >= MAX_MEMORY_ENTRIES) { evictOldest(); } - memoryCache.set(toolCallId, { + memoryCache.set(key, { reasoning, provider, model, @@ -151,12 +183,16 @@ export function cacheReasoning( }); try { - setReasoningCache(toolCallId, provider, model, reasoning, TTL_MS); + setReasoningCache(key, provider, model, reasoning, TTL_MS); } catch { // DB persistence failure is non-fatal; memory cache still serves the hot path. } } +function buildAssistantMessageCacheKey(requestId: string, messageIndex: number): string { + return `request:${requestId}:message:${messageIndex}`; +} + /** * Cache reasoning for multiple tool_call IDs (same reasoning content). */ @@ -172,15 +208,16 @@ export function cacheReasoningBatch( } /** - * Capture reasoning_content from an assistant message with tool calls. - * Returns the number of tool_call IDs cached. + * Capture reasoning_content from an assistant message. + * Returns the number of cache keys written. */ export function cacheReasoningFromAssistantMessage( message: AssistantMessageLike | null | undefined, provider: string, - model: string + model: string, + context?: AssistantMessageCacheContext ): number { - if (!message || message.role !== "assistant" || !Array.isArray(message.tool_calls)) { + if (!message || message.role !== "assistant") { return 0; } @@ -192,10 +229,26 @@ export function cacheReasoningFromAssistantMessage( : ""; if (!reasoning) return 0; - const toolCallIds = (message.tool_calls as ToolCallLike[]) - .map((toolCall) => (typeof toolCall.id === "string" ? toolCall.id : "")) - .filter((id) => id.length > 0); - if (toolCallIds.length === 0) return 0; + const toolCallIds = Array.isArray(message.tool_calls) + ? (message.tool_calls as ToolCallLike[]) + .map((toolCall) => (typeof toolCall.id === "string" ? toolCall.id : "")) + .filter((id) => id.length > 0) + : []; + if (toolCallIds.length === 0) { + const requestId = context?.requestId?.trim(); + const messageIndex = context?.messageIndex; + if (!requestId || typeof messageIndex !== "number" || !Number.isInteger(messageIndex)) { + return 0; + } + + cacheReasoningByKey( + buildAssistantMessageCacheKey(requestId, messageIndex), + provider, + model, + reasoning + ); + return 1; + } cacheReasoningBatch(toolCallIds, provider, model, reasoning); return toolCallIds.length; diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 17030c1703..aa503c1d76 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -1,3 +1,5 @@ +import { isDeepSeekReasoningModel } from "../../services/reasoningCache.ts"; + /** * Shared sanitizers for tool payloads that arrive from IDEs/SDKs with * JSON Schema numeric constraints encoded as strings or invalid descriptions. @@ -196,9 +198,17 @@ export function sanitizeToolId(id: string | undefined): string { export function injectEmptyReasoningContentForToolCalls( messages: unknown, - provider: unknown + provider: unknown, + model: unknown ): unknown { - if (!Array.isArray(messages) || String(provider || "").toLowerCase() !== "deepseek") { + if ( + !Array.isArray(messages) || + !isDeepSeekReasoningModel({ + provider: String(provider ?? ""), + model: String(model ?? ""), + thinkingEnabled: true, + }) + ) { return messages; } diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 41f434fa06..dec3b92574 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -9,8 +9,9 @@ import { } from "./helpers/schemaCoercion.ts"; import { getRequestTranslator, getResponseTranslator } from "./registry.ts"; import { bootstrapTranslatorRegistry } from "./bootstrap.ts"; -import { normalizeThinkingConfig } from "../services/provider.ts"; +import { hasThinkingConfig, normalizeThinkingConfig } from "../services/provider.ts"; import { applyThinkingBudget } from "../services/thinkingBudget.ts"; +import { supportsReasoning } from "../services/modelCapabilities.ts"; import { normalizeRoles } from "../services/roleNormalizer.ts"; import { lookupReasoning, @@ -76,6 +77,43 @@ function normalizeOpenAIResponsesRequest(body) { return normalized; } +function getReasoningCacheRequestId(body: Record | null | undefined): string { + if (!body || typeof body !== "object") return ""; + + const requestId = + body._reasoningCacheRequestId ?? + body.reasoningCacheRequestId ?? + body.request_id ?? + body.requestId; + return typeof requestId === "string" ? requestId.trim() : ""; +} + +function getAssistantMessageCacheKey( + body: Record | null | undefined, + messageIndex: number +): string { + const requestId = getReasoningCacheRequestId(body); + return requestId ? `request:${requestId}:message:${messageIndex}` : ""; +} + +function hasNonEmptyReasoningContent(message: Record): boolean { + return typeof message.reasoning_content === "string" && message.reasoning_content.length > 0; +} + +function hasReasoningContentField(message: Record): boolean { + return Object.prototype.hasOwnProperty.call(message, "reasoning_content"); +} + +function isDeepSeekReplayTarget(provider: unknown, model: unknown): boolean { + const normalizedProvider = String(provider ?? "") + .trim() + .toLowerCase(); + const normalizedModel = String(model ?? "") + .trim() + .toLowerCase(); + return normalizedProvider === "deepseek" || normalizedModel.includes("deepseek"); +} + /** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */ /** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */ /** @param options.preserveCacheControl - When true, preserve client-side cache_control markers (for Claude Code, etc.) */ @@ -197,7 +235,7 @@ export function translateRequest( } if (targetFormat === FORMATS.OPENAI && result.messages && Array.isArray(result.messages)) { - result.messages = injectEmptyReasoningContentForToolCalls(result.messages, provider); + result.messages = injectEmptyReasoningContentForToolCalls(result.messages, provider, model); } // Ensure unique tool_call ids on final payload (translators may have introduced duplicates) @@ -214,30 +252,46 @@ export function translateRequest( // clients omit it from the conversation history. Without this, DeepSeek V4 // returns 400: "The reasoning_content in the thinking mode must be passed // back to the API." - const isReasoner = requiresReasoningReplay(String(provider ?? ""), String(model ?? "")); + const normalizedProvider = String(provider ?? ""); + const normalizedModel = String(model ?? ""); + const isReasoner = requiresReasoningReplay({ + provider: normalizedProvider, + model: normalizedModel, + thinkingEnabled: hasThinkingConfig(result), + supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }), + }); if (isReasoner && result.messages && Array.isArray(result.messages)) { - for (const msg of result.messages) { - if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) { - // Skip if client already provided real reasoning_content - if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) { + const canReplayReasoningOnly = isDeepSeekReplayTarget(normalizedProvider, normalizedModel); + + for (const [messageIndex, msg] of result.messages.entries()) { + if (msg.role !== "assistant") continue; + + const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0; + const shouldReplayReasoningOnly = + !hasToolCalls && canReplayReasoningOnly && hasReasoningContentField(msg); + + if (!hasToolCalls && !shouldReplayReasoningOnly) continue; + + // Skip if client already provided real reasoning_content + if (hasNonEmptyReasoningContent(msg)) { + continue; + } + + const cacheKey = hasToolCalls + ? msg.tool_calls[0]?.id + : getAssistantMessageCacheKey(result, messageIndex); + if (cacheKey) { + const cached = lookupReasoning(cacheKey); + if (cached) { + msg.reasoning_content = cached; + recordReplay(); continue; } + } - // Try cache lookup using first tool_call ID - const firstToolId = msg.tool_calls[0]?.id; - if (firstToolId) { - const cached = lookupReasoning(firstToolId); - if (cached) { - msg.reasoning_content = cached; - recordReplay(); - continue; - } - } - - // Legacy fallback — empty string (works for older DeepSeek versions) - if (msg.reasoning_content === undefined) { - msg.reasoning_content = ""; - } + // Legacy fallback — empty string (works for older DeepSeek versions) + if (hasToolCalls && msg.reasoning_content === undefined) { + msg.reasoning_content = ""; } } } else if ( diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index 7fa835239f..a6dbae7e24 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -19,6 +19,7 @@ process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-reasoning-")); import { cacheReasoningFromAssistantMessage, cacheReasoning, + cacheReasoningByKey, cacheReasoningBatch, deleteReasoningCacheEntry, getReasoningCacheServiceEntries, @@ -26,11 +27,13 @@ import { recordReplay, getReasoningCacheServiceStats, clearReasoningCacheAll, + isDeepSeekReasoningModel, requiresReasoningReplay, cleanupReasoningCache, } from "../../open-sse/services/reasoningCache.ts"; import { translateRequest } from "../../open-sse/translator/index.ts"; import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { ensureToolCallIds } from "../../open-sse/translator/helpers/toolCallHelper.ts"; import { getDbInstance } from "../../src/lib/db/core.ts"; import { getReasoningCache, setReasoningCache } from "../../src/lib/db/reasoningCache.ts"; import { DELETE, GET } from "../../src/app/api/cache/reasoning/route.ts"; @@ -124,6 +127,37 @@ describe("Reasoning Replay Cache — Service Layer", () => { assert.equal(lookupReasoning("call_capture_2"), "Captured assistant reasoning"); }); + it("should keep request message cache keys stable when tool call IDs change", () => { + clearReasoningCacheAll(); + + const requestId = "req_reasoning_stable"; + const messageIndex = 2; + const cacheKey = `${requestId}:${messageIndex}`; + const body = { + messages: [ + { + role: "assistant", + tool_calls: [ + { + id: "call_before_normalization", + type: "function", + function: { name: "lookup", arguments: { city: "Seoul" } }, + }, + ], + }, + { role: "tool", content: "Sunny" }, + ], + }; + + cacheReasoning(cacheKey, "deepseek", "deepseek-reasoner", "Stable cached reasoning"); + const originalToolCallId = body.messages[0].tool_calls[0].id; + + ensureToolCallIds(body, { use9CharId: true }); + + assert.notEqual(body.messages[0].tool_calls[0].id, originalToolCallId); + assert.equal(lookupReasoning(cacheKey), "Stable cached reasoning"); + }); + it("should capture provider reasoning alias when reasoning_content is absent", () => { clearReasoningCacheAll(); @@ -141,6 +175,53 @@ describe("Reasoning Replay Cache — Service Layer", () => { assert.equal(lookupReasoning("call_capture_alias"), "Alias reasoning"); }); + it("should cache assistant reasoning without tool calls by request and message index", () => { + clearReasoningCacheAll(); + + const cached = cacheReasoningFromAssistantMessage( + { + role: "assistant", + reasoning_content: "No tool call reasoning", + }, + "deepseek", + "deepseek-reasoner", + { requestId: "req_no_tools", messageIndex: 3 } + ); + + assert.equal(cached, 1); + assert.equal(lookupReasoning("request:req_no_tools:message:3"), "No tool call reasoning"); + }); + + it("should skip assistant reasoning without tool calls when stable key context is absent", () => { + clearReasoningCacheAll(); + + const cached = cacheReasoningFromAssistantMessage( + { + role: "assistant", + reasoning_content: "Missing key context", + }, + "deepseek", + "deepseek-reasoner" + ); + + assert.equal(cached, 0); + assert.equal(lookupReasoning("request:req_missing:message:0"), null); + }); + + it("should store arbitrary reasoning cache keys", () => { + clearReasoningCacheAll(); + + cacheReasoningByKey( + "request:req_direct:message:1", + "deepseek", + "deepseek-reasoner", + "Keyed plan" + ); + + assert.equal(lookupReasoning("request:req_direct:message:1"), "Keyed plan"); + assert.equal(getReasoningCache("request:req_direct:message:1")?.reasoning, "Keyed plan"); + }); + it("should not overwrite if same tool_call_id is cached again", () => { cacheReasoning("call_overwrite", "deepseek", "deepseek-chat", "First reasoning"); cacheReasoning("call_overwrite", "deepseek", "deepseek-chat", "Updated reasoning"); @@ -311,39 +392,83 @@ describe("Reasoning Replay Cache — Service Layer", () => { describe("Reasoning Replay Cache — Provider Detection", () => { it("should detect deepseek as requiring replay", () => { - assert.equal(requiresReasoningReplay("deepseek", "deepseek-chat"), true); + assert.equal(requiresReasoningReplay({ provider: "deepseek", model: "deepseek-chat" }), true); }); it("should detect opencode-go as requiring replay", () => { - assert.equal(requiresReasoningReplay("opencode-go", "some-model"), true); + assert.equal(requiresReasoningReplay({ provider: "opencode-go", model: "some-model" }), true); }); it("should detect siliconflow as requiring replay", () => { - assert.equal(requiresReasoningReplay("siliconflow", "deepseek-r1"), true); + assert.equal(requiresReasoningReplay({ provider: "siliconflow", model: "deepseek-r1" }), true); }); it("should detect deepseek-r1 model pattern", () => { - assert.equal(requiresReasoningReplay("unknown-provider", "deepseek-r1"), true); + assert.equal( + requiresReasoningReplay({ provider: "unknown-provider", model: "deepseek-r1" }), + true + ); }); it("should detect deepseek-reasoner model pattern", () => { - assert.equal(requiresReasoningReplay("unknown-provider", "deepseek-reasoner"), true); + assert.equal( + requiresReasoningReplay({ provider: "unknown-provider", model: "deepseek-reasoner" }), + true + ); + }); + + it("should detect DeepSeek V4 model pattern", () => { + assert.equal( + requiresReasoningReplay({ provider: "unknown-provider", model: "deepseek/v4-pro" }), + true + ); + }); + + it("should detect DeepSeek V4 thinking mode explicitly", () => { + assert.equal( + isDeepSeekReasoningModel({ + provider: "unknown-provider", + model: "deepseek-v4.flash", + thinkingEnabled: true, + }), + true + ); + }); + + it("should NOT detect DeepSeek V4 when thinking mode is disabled", () => { + assert.equal( + isDeepSeekReasoningModel({ + provider: "unknown-provider", + model: "deepseek-v4.flash", + thinkingEnabled: false, + }), + false + ); }); it("should detect kimi-k2 model pattern", () => { - assert.equal(requiresReasoningReplay("unknown-provider", "kimi-k2.5"), true); + assert.equal( + requiresReasoningReplay({ provider: "unknown-provider", model: "kimi-k2.5" }), + true + ); }); it("should detect qwq model pattern", () => { - assert.equal(requiresReasoningReplay("unknown-provider", "qwq-32b-preview"), true); + assert.equal( + requiresReasoningReplay({ provider: "unknown-provider", model: "qwq-32b-preview" }), + true + ); }); it("should detect qwen-thinking model pattern", () => { - assert.equal(requiresReasoningReplay("unknown-provider", "qwen3-thinking-235b"), true); + assert.equal( + requiresReasoningReplay({ provider: "unknown-provider", model: "qwen3-thinking-235b" }), + true + ); }); it("should detect GLM thinking model pattern", () => { - assert.equal(requiresReasoningReplay("glm", "glm-5-thinking"), true); + assert.equal(requiresReasoningReplay({ provider: "glm", model: "glm-5-thinking" }), true); }); it("should detect xiaomi-mimo provider", () => { @@ -361,11 +486,11 @@ describe("Reasoning Replay Cache — Provider Detection", () => { }); it("should NOT detect a generic openai model", () => { - assert.equal(requiresReasoningReplay("openai", "gpt-4o"), false); + assert.equal(requiresReasoningReplay({ provider: "openai", model: "gpt-4o" }), false); }); it("should NOT detect claude as requiring replay", () => { - assert.equal(requiresReasoningReplay("anthropic", "claude-opus-4"), false); + assert.equal(requiresReasoningReplay({ provider: "anthropic", model: "claude-opus-4" }), false); }); }); diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 9cc7b71ab2..718fda3070 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -121,6 +121,61 @@ test("sanitizeOpenAIResponse strips reasoning_details-derived reasoning_content assert.equal((sanitized as any).choices[0].message.reasoning_content, undefined); }); +test("sanitizeOpenAIResponse preserves DeepSeek V4 reasoning_content with visible text", () => { + const sanitized = sanitizeOpenAIResponse({ + model: "deepseek-v4-pro", + choices: [ + { + message: { + role: "assistant", + content: "Visible answer", + reasoning_content: "DeepSeek reasoning", + }, + }, + ], + }); + + assert.equal((sanitized as any).choices[0].message.content, "Visible answer"); + assert.equal((sanitized as any).choices[0].message.reasoning_content, "DeepSeek reasoning"); +}); + +test("sanitizeOpenAIResponse preserves DeepSeek V4 reasoning_details with visible text", () => { + const sanitized = sanitizeOpenAIResponse({ + model: "deepseek-v4/reasoner", + choices: [ + { + message: { + role: "assistant", + content: "Visible answer", + reasoning_details: [ + { type: "reasoning.text", text: "first " }, + { type: "thinking", content: "second" }, + ], + }, + }, + ], + }); + + assert.equal((sanitized as any).choices[0].message.reasoning_content, "first second"); +}); + +test("sanitizeOpenAIResponse still strips non-DeepSeek reasoning_content with visible text", () => { + const sanitized = sanitizeOpenAIResponse({ + model: "o3-mini", + choices: [ + { + message: { + role: "assistant", + content: "Visible answer", + reasoning_content: "OpenAI reasoning", + }, + }, + ], + }); + + assert.equal((sanitized as any).choices[0].message.reasoning_content, undefined); +}); + test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => { const sanitized = sanitizeOpenAIResponse({ model: "openrouter/model", diff --git a/tests/unit/schema-coercion.test.ts b/tests/unit/schema-coercion.test.ts index 669a6249e5..511310e5b6 100644 --- a/tests/unit/schema-coercion.test.ts +++ b/tests/unit/schema-coercion.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert"; import { coerceSchemaNumericFields, coerceToolSchemas, + injectEmptyReasoningContentForToolCalls, sanitizeToolDescription, sanitizeToolDescriptions, } from "../../open-sse/translator/helpers/schemaCoercion.ts"; @@ -112,3 +113,32 @@ test("sanitizeToolDescriptions works on arrays", () => { assert.strictEqual(result[0].description, ""); assert.strictEqual(result[1].function.description, "42"); }); + +test("injectEmptyReasoningContentForToolCalls supports DeepSeek V4 models across providers", () => { + const messages = [ + { role: "user", content: "hello" }, + { role: "assistant", tool_calls: [{ id: "call_1" }] }, + ]; + + for (const provider of ["openrouter", "fireworks", "deepinfra"]) { + const result = injectEmptyReasoningContentForToolCalls( + messages, + provider, + "accounts/fireworks/models/deepseek-v4-pro" + ) as Array<{ reasoning_content?: string }>; + + assert.equal(result[1].reasoning_content, ""); + } +}); + +test("injectEmptyReasoningContentForToolCalls skips non-DeepSeek V4 models", () => { + const messages = [{ role: "assistant", tool_calls: [{ id: "call_1" }] }]; + + const result = injectEmptyReasoningContentForToolCalls( + messages, + "deepseek", + "deepseek-reasoner" + ) as Array<{ reasoning_content?: string }>; + + assert.equal(result[0].reasoning_content, undefined); +}); diff --git a/tests/unit/tool-request-sanitization.test.ts b/tests/unit/tool-request-sanitization.test.ts index 08e458b7b1..e406d657a7 100644 --- a/tests/unit/tool-request-sanitization.test.ts +++ b/tests/unit/tool-request-sanitization.test.ts @@ -159,8 +159,12 @@ test("tool sanitization: injects empty reasoning_content only for DeepSeek tool- }, ]; - const deepseekMessages = injectEmptyReasoningContentForToolCalls(messages, "deepseek"); - const openaiMessages = injectEmptyReasoningContentForToolCalls(messages, "openai"); + const deepseekMessages = injectEmptyReasoningContentForToolCalls( + messages, + "deepseek", + "deepseek-v4-flash" + ); + const openaiMessages = injectEmptyReasoningContentForToolCalls(messages, "openai", "gpt-4o"); assert.equal(deepseekMessages[1].reasoning_content, ""); assert.equal(openaiMessages[1].reasoning_content, undefined); @@ -170,7 +174,7 @@ test("translateRequest injects reasoning_content for DeepSeek assistant tool cal const translated = translateRequest( FORMATS.OPENAI, FORMATS.OPENAI, - "deepseek-reasoner", + "deepseek-v4-flash", { messages: [ { role: "user", content: "hello" }, diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index 9c9003374d..0f2ab57165 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -6,6 +6,10 @@ const openaiHelper = await import("../../open-sse/translator/helpers/openaiHelpe const claudeHelper = await import("../../open-sse/translator/helpers/claudeHelper.ts"); const geminiHelper = await import("../../open-sse/translator/helpers/geminiHelper.ts"); const toolCallHelper = await import("../../open-sse/translator/helpers/toolCallHelper.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { cacheReasoningByKey, clearReasoningCacheAll, getReasoningCacheServiceStats } = + await import("../../open-sse/services/reasoningCache.ts"); const originalMathRandom = Math.random; @@ -132,14 +136,18 @@ test("schemaCoercion sanitizes descriptions, tool schemas, tool ids and deepseek { role: "assistant", tool_calls: [{ id: "call_2" }], reasoning_content: "keep" }, { role: "user", tool_calls: [{ id: "call_3" }] }, ], - "deepseek" + "deepseek", + "deepseek-v4-flash" ); assert.equal(injected[0].reasoning_content, ""); assert.equal(injected[1].reasoning_content, "keep"); assert.equal(injected[2].reasoning_content, undefined); assert.equal( - schemaCoercion.injectEmptyReasoningContentForToolCalls([{ role: "assistant" }], "openai")[0] - .reasoning_content, + schemaCoercion.injectEmptyReasoningContentForToolCalls( + [{ role: "assistant" }], + "openai", + "gpt-4o" + )[0].reasoning_content, undefined ); }); @@ -480,3 +488,63 @@ test("toolCallHelper normalizes ids, links tool responses and inserts missing to assert.equal(toolCallHelper.hasToolResults({ role: "user", content: [] }, []), false); assert.deepEqual(toolCallHelper.fixMissingToolResponses({ messages: null }), { messages: null }); }); + +test("translateRequest replays cached DeepSeek reasoning messages without tool calls", () => { + clearReasoningCacheAll(); + cacheReasoningByKey( + "request:req_reasoning_only:message:1", + "deepseek", + "deepseek-reasoner", + "cached reasoning only" + ); + + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-reasoner", + { + _reasoningCacheRequestId: "req_reasoning_only", + messages: [ + { role: "user", content: "solve this" }, + { role: "assistant", content: "answer", reasoning_content: "" }, + ], + }, + false, + null, + "deepseek" + ); + + assert.equal(result.messages[1].reasoning_content, "cached reasoning only"); + assert.equal(getReasoningCacheServiceStats().replays, 1); + clearReasoningCacheAll(); +}); + +test("translateRequest does not replay reasoning-only messages for non-DeepSeek models", () => { + clearReasoningCacheAll(); + cacheReasoningByKey( + "request:req_kimi_reasoning_only:message:1", + "kimi", + "kimi-k2.5", + "cached kimi reasoning" + ); + + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "kimi-k2.5", + { + _reasoningCacheRequestId: "req_kimi_reasoning_only", + messages: [ + { role: "user", content: "solve this" }, + { role: "assistant", content: "answer", reasoning_content: "" }, + ], + }, + false, + null, + "kimi" + ); + + assert.equal(result.messages[1].reasoning_content, ""); + assert.equal(getReasoningCacheServiceStats().replays, 0); + clearReasoningCacheAll(); +});