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 <owen@kangheewon.dev>
This commit is contained in:
diegosouzapw
2026-05-14 09:20:21 -03:00
parent 35a55d73f3
commit 18ef28ea77
10 changed files with 495 additions and 76 deletions

View File

@@ -1056,8 +1056,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ 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<string, RegistryEntry> = {
{ 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<string, RegistryEntry> = {
{ 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<string, RegistryEntry> = {
// 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<string, RegistryEntry> = {
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<string, RegistryEntry> = {
{ 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<string, RegistryEntry> = {
// 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<string, RegistryEntry> = {
{ 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<string, RegistryEntry> = {
{ 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)" },

View File

@@ -27,6 +27,10 @@ const ALLOWED_RESPONSES_USAGE_FIELDS = new Set([
type JsonRecord = Record<string, unknown>;
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 <think> 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;
}

View File

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

View File

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

View File

@@ -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<string, unknown> | 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<string, unknown> | null | undefined,
messageIndex: number
): string {
const requestId = getReasoningCacheRequestId(body);
return requestId ? `request:${requestId}:message:${messageIndex}` : "";
}
function hasNonEmptyReasoningContent(message: Record<string, unknown>): boolean {
return typeof message.reasoning_content === "string" && message.reasoning_content.length > 0;
}
function hasReasoningContentField(message: Record<string, unknown>): 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 (

View File

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

View File

@@ -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",

View File

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

View File

@@ -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" },

View File

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