diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index d621b830f5..6d3b887819 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -21,6 +21,7 @@ const { applyContextToAgent, isMemoryAgentEnabled, recordCollectedUsage, + isDeepSeekReasoningProvider, GenerationJobManager, getTransactionsConfig, resolveRecursionLimit, @@ -876,12 +877,27 @@ class AgentClient extends BaseClient { agents: [this.options.agent, ...(this.agentConfigs ? this.agentConfigs.values() : [])], }); + /** Spoof `Providers.DEEPSEEK` so the SDK preserves `reasoning_content` on tool turns (#13366). */ + const hasDeepSeekAgent = (agent) => + agent != null && + isDeepSeekReasoningProvider(agent.provider, agent.model_parameters?.model ?? agent.model); + const needsDeepSeekFormat = + hasDeepSeekAgent(this.options.agent) || + (this.agentConfigs != null && + Array.from(this.agentConfigs.values()).some(hasDeepSeekAgent)); + const formatOptions = needsDeepSeekFormat ? { provider: Providers.DEEPSEEK } : undefined; let { messages: initialMessages, indexTokenCountMap, summary: initialSummary, boundaryTokenAdjustment, - } = formatAgentMessages(payload, this.indexTokenCountMap, toolSet, skillPrimeResult?.skills); + } = formatAgentMessages( + payload, + this.indexTokenCountMap, + toolSet, + skillPrimeResult?.skills, + formatOptions, + ); if (boundaryTokenAdjustment) { logger.debug( `[AgentClient] Boundary token adjustment: ${boundaryTokenAdjustment.original} → ${boundaryTokenAdjustment.adjusted} (${boundaryTokenAdjustment.remainingChars}/${boundaryTokenAdjustment.totalChars} chars)`, diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index 59f8959f39..0cc280d778 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -1,7 +1,11 @@ import { Providers } from '@librechat/agents'; import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages'; -import { extractDiscoveredToolsFromHistory, getReasoningKey } from './run'; +import { + extractDiscoveredToolsFromHistory, + getReasoningKey, + isDeepSeekReasoningProvider, +} from './run'; describe('extractDiscoveredToolsFromHistory', () => { it('extracts tool names from tool_search JSON output', () => { @@ -147,3 +151,75 @@ describe('getReasoningKey', () => { expect(reasoningKey).toBe('reasoning'); }); }); + +describe('isDeepSeekReasoningProvider', () => { + it('returns true for the direct deepseek provider regardless of model', () => { + expect(isDeepSeekReasoningProvider(Providers.DEEPSEEK)).toBe(true); + expect(isDeepSeekReasoningProvider(Providers.DEEPSEEK, 'deepseek-chat')).toBe(true); + expect(isDeepSeekReasoningProvider(Providers.DEEPSEEK, 'unrelated')).toBe(true); + }); + + it('returns true for openrouter when the model id is namespaced deepseek', () => { + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, 'deepseek/deepseek-v4-pro')).toBe( + true, + ); + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, 'DeepSeek/DeepSeek-V4')).toBe(true); + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, 'deepseek-r1')).toBe(true); + }); + + it("strips OpenRouter's `~` latest-routing prefix before matching", () => { + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, '~deepseek/deepseek-v4')).toBe(true); + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, '~deepseek/r1')).toBe(true); + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, '~deepseek-chat')).toBe(true); + }); + + it('matches the provider string case-insensitively (custom endpoint names)', () => { + expect(isDeepSeekReasoningProvider('OpenRouter', 'deepseek/deepseek-v4')).toBe(true); + expect(isDeepSeekReasoningProvider('OPENROUTER', 'deepseek/deepseek-v4')).toBe(true); + expect(isDeepSeekReasoningProvider('DeepSeek')).toBe(true); + }); + + it('matches custom-named endpoints and direct DeepSeek-compatible proxies via the fallback', () => { + expect(isDeepSeekReasoningProvider('openai', 'deepseek/deepseek-v4')).toBe(true); + expect(isDeepSeekReasoningProvider('MyCustomEndpoint', '~deepseek/r1')).toBe(true); + expect(isDeepSeekReasoningProvider(undefined, 'deepseek/deepseek-chat')).toBe(true); + expect(isDeepSeekReasoningProvider(null, 'deepseek/deepseek-v4')).toBe(true); + expect(isDeepSeekReasoningProvider('', 'deepseek/deepseek-v4')).toBe(true); + expect(isDeepSeekReasoningProvider('openai', 'deepseek-chat')).toBe(true); + expect(isDeepSeekReasoningProvider('MyDeepSeekProxy', 'deepseek-reasoner')).toBe(true); + expect(isDeepSeekReasoningProvider(undefined, 'deepseek-r1')).toBe(true); + expect(isDeepSeekReasoningProvider(undefined, '~deepseek-chat')).toBe(true); + }); + + it('returns false for openrouter with non-deepseek models', () => { + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, 'anthropic/claude-opus-4-7')).toBe( + false, + ); + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, 'openai/gpt-5')).toBe(false); + expect( + isDeepSeekReasoningProvider(Providers.OPENROUTER, 'meta-llama/llama-3.1-70b-instruct'), + ).toBe(false); + }); + + it('returns false when the model is missing on openrouter', () => { + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER)).toBe(false); + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, null)).toBe(false); + expect(isDeepSeekReasoningProvider(Providers.OPENROUTER, '')).toBe(false); + }); + + it('returns false for nullish provider input without a DeepSeek-prefixed model', () => { + expect(isDeepSeekReasoningProvider(undefined, 'gpt-5')).toBe(false); + expect(isDeepSeekReasoningProvider(null, 'claude-opus-4-7')).toBe(false); + expect(isDeepSeekReasoningProvider('', 'gemini-2.5-pro')).toBe(false); + }); + + it('does not match cloned/distilled slugs that merely contain "deepseek" later in the id', () => { + expect( + isDeepSeekReasoningProvider(Providers.OPENROUTER, 'community/not-a-deepseek-clone'), + ).toBe(false); + expect( + isDeepSeekReasoningProvider(Providers.OPENROUTER, 'mistral/deepseek-distilled-foo'), + ).toBe(false); + expect(isDeepSeekReasoningProvider(undefined, 'community/deepseek-r1')).toBe(false); + }); +}); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index e01f123e6a..10437096ce 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -238,6 +238,38 @@ export function getReasoningKey( return reasoningKey; } +const DEEPSEEK_MODEL_PATTERN = /^deepseek(?:[-/]|$)/i; +const OPENROUTER_LATEST_ROUTING_PREFIX = /^~/; + +function matchesDeepSeekModel(model?: string | null): boolean { + if (typeof model !== 'string' || model.length === 0) { + return false; + } + return DEEPSEEK_MODEL_PATTERN.test(model.replace(OPENROUTER_LATEST_ROUTING_PREFIX, '')); +} + +/** + * Whether the (provider, model) pair targets DeepSeek's thinking-mode + * tool-calling contract, which requires `reasoning_content` to be replayed + * on every prior assistant message that emitted `tool_calls`. + * @see https://api-docs.deepseek.com/guides/thinking_mode#tool-calls + */ +export function isDeepSeekReasoningProvider( + provider: string | Providers | undefined | null, + model?: string | null, +): boolean { + if (typeof provider === 'string' && provider.length > 0) { + const normalized = provider.toLowerCase(); + if (normalized === Providers.DEEPSEEK) { + return true; + } + if (normalized === Providers.OPENROUTER) { + return matchesDeepSeekModel(model); + } + } + return matchesDeepSeekModel(model); +} + type RunAgent = Omit & { tools?: GenericTool[]; maxContextTokens?: number; diff --git a/packages/api/src/endpoints/openai/config.backward-compat.spec.ts b/packages/api/src/endpoints/openai/config.backward-compat.spec.ts index f19540481f..11e9357ee8 100644 --- a/packages/api/src/endpoints/openai/config.backward-compat.spec.ts +++ b/packages/api/src/endpoints/openai/config.backward-compat.spec.ts @@ -339,6 +339,7 @@ describe('getOpenAIConfig - Backward Compatibility', () => { model: 'DeepSeek-R1', user: 'some_user_id', apiKey: 'some_azure_key', + includeReasoningContent: true, }, configOptions: { baseURL: 'https://some_endpoint_name.models.ai.azure.com/v1/', diff --git a/packages/api/src/endpoints/openai/llm.spec.ts b/packages/api/src/endpoints/openai/llm.spec.ts index e6553e96c0..366176978a 100644 --- a/packages/api/src/endpoints/openai/llm.spec.ts +++ b/packages/api/src/endpoints/openai/llm.spec.ts @@ -962,6 +962,93 @@ describe('getOpenAILLMConfig', () => { expect(disabled.llmConfig).not.toHaveProperty('promptCache'); expect(dropped.llmConfig).not.toHaveProperty('promptCache'); }); + + it('should set includeReasoningContent for DeepSeek models via OpenRouter', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: 'deepseek/deepseek-v4-pro', + }, + }); + + expect(result.llmConfig).toHaveProperty('includeReasoningContent', true); + }); + + it('should set includeReasoningContent case-insensitively for OpenRouter DeepSeek models', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: 'DeepSeek/DeepSeek-V4', + }, + }); + + expect(result.llmConfig).toHaveProperty('includeReasoningContent', true); + }); + + it('should set includeReasoningContent for OpenRouter DeepSeek models with the latest-routing `~` prefix', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: '~deepseek/deepseek-v4-pro', + }, + }); + + expect(result.llmConfig).toHaveProperty('includeReasoningContent', true); + }); + + it('should not set includeReasoningContent for non-DeepSeek OpenRouter models', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: 'anthropic/claude-opus-4-7', + }, + }); + + expect(result.llmConfig).not.toHaveProperty('includeReasoningContent'); + }); + + it('should set includeReasoningContent for DeepSeek-flavored models outside OpenRouter (custom proxies)', () => { + const directLike = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: false, + modelOptions: { + model: 'deepseek-chat', + }, + }); + expect(directLike.llmConfig).toHaveProperty('includeReasoningContent', true); + + const customProxy = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: false, + modelOptions: { + model: 'deepseek/deepseek-v4-pro', + }, + }); + expect(customProxy.llmConfig).toHaveProperty('includeReasoningContent', true); + }); + + it('should not set includeReasoningContent for non-DeepSeek models outside OpenRouter', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: false, + modelOptions: { + model: 'gpt-4', + }, + }); + + expect(result.llmConfig).not.toHaveProperty('includeReasoningContent'); + }); }); describe('Verbosity Handling', () => { diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index 0e27e3d95c..d2148cebb4 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -486,6 +486,14 @@ export function getOpenAILLMConfig({ llmConfig.promptCache = true; } + /** DeepSeek thinking-mode requires `reasoning_content` replay on tool turns (#13366). */ + if ( + typeof modelOptions.model === 'string' && + /^deepseek(?:[-/]|$)/i.test(modelOptions.model.replace(/^~/, '')) + ) { + llmConfig.includeReasoningContent = true; + } + /** * Note: OpenAI reasoning models (o1/o3/gpt-5) do not support temperature and other sampling parameters * Exception: gpt-5-chat and versioned models like gpt-5.1 DO support these parameters diff --git a/packages/api/src/types/openai.ts b/packages/api/src/types/openai.ts index 646c814c2d..a04d2cb815 100644 --- a/packages/api/src/types/openai.ts +++ b/packages/api/src/types/openai.ts @@ -30,6 +30,8 @@ export type OpenAIConfiguration = OpenAIClientOptions['configuration']; export type OAIClientOptions = Omit & { include_reasoning?: boolean; + /** Replays `reasoning_content` on tool-bearing turns (DeepSeek thinking-mode, #13366). */ + includeReasoningContent?: boolean; promptCache?: boolean; _lc_stream_delay?: number; verbosity?: string | null;