mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
@@ -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)" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user