fix(DeepSeekAI): capture and echo reasoning_content for thinking mode tool calls
DeepSeek thinking mode (deepseek-v4-flash/pro with reasoning enabled) returns reasoning_content in every delta. For requests carrying tools, the reasoning_content of previous assistant messages MUST be passed back in all subsequent requests, otherwise DeepSeek answers 400 'The reasoning_content ... must be passed back'. Changes: - Bump OpenAI SDK 2.5.0 -> 2.12.0 (JsonPatch public API required to read/write the unknown reasoning_content property; 2.5.0 only stores it internally). - Fix BotSharp.Plugin.OpenAI image quality enum names for SDK 2.12 (GeneratedImageQuality.Low -> LowQuality, Medium -> MediumQuality). - Add RoleDialogModel.ReasoningContent (serialized as reasoning_content) and persist it through DialogElement/DialogMetaData/ConversationStorage. - DeepSeekAI ChatCompletionProvider: * capture reasoning_content from non-streaming responses (ExtractReasoningContent) * capture per-delta reasoning_content in the streaming loop and accumulate it * set ReasoningContent on assistant/function response messages * echo reasoning_content back via AssistantChatMessage JsonPatch in PrepareOptions - RoutingService.InvokeAgent: copy response.ReasoningContent onto the stored Function/Assistant dialog so the echo survives the tool-call round trip.
This commit is contained in:
parent
ffc9866069
commit
ce08fc132b
|
|
@ -45,7 +45,7 @@
|
|||
<PackageVersion Include="Whisper.net.Runtime" Version="1.8.1" />
|
||||
<PackageVersion Include="NCrontab" Version="3.3.3" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
|
||||
<PackageVersion Include="OpenAI" Version="2.5.0" />
|
||||
<PackageVersion Include="OpenAI" Version="2.12.0" />
|
||||
<PackageVersion Include="MailKit" Version="4.11.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.8" />
|
||||
<PackageVersion Include="MySql.Data" Version="9.0.0" />
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ public class DialogElement
|
|||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Payload { get; set; }
|
||||
|
||||
[JsonPropertyName("reasoning_content")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReasoningContent { get; set; }
|
||||
|
||||
public DialogElement()
|
||||
{
|
||||
|
||||
|
|
@ -114,6 +118,10 @@ public class DialogMetaData
|
|||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ToolCallId { get; set; }
|
||||
|
||||
[JsonPropertyName("reasoning_content")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReasoningContent { get; set; }
|
||||
|
||||
[JsonPropertyName("sender_id")]
|
||||
public string? SenderId { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,15 @@ public class RoleDialogModel : ITrackableMessage
|
|||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? FunctionArgs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DeepSeek thinking mode: the chain-of-thought text returned by the model in
|
||||
/// <c>reasoning_content</c>. Must be echoed back to the API in subsequent requests
|
||||
/// when the conversation carries tools, otherwise DeepSeek answers 400.
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonPropertyName("reasoning_content")]
|
||||
public string? ReasoningContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set this flag is in OnFunctionExecuting, if true, it won't be executed by InvokeFunction.
|
||||
/// </summary>
|
||||
|
|
@ -192,6 +201,7 @@ public class RoleDialogModel : ITrackableMessage
|
|||
FunctionArgs = source.FunctionArgs,
|
||||
FunctionName = source.FunctionName,
|
||||
ToolCallId = source.ToolCallId,
|
||||
ReasoningContent = source.ReasoningContent,
|
||||
Indication = source.Indication,
|
||||
PostbackFunctionName = source.PostbackFunctionName,
|
||||
RichContent = source.RichContent,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ public class ConversationStorage : IConversationStorage
|
|||
FunctionName = meta?.FunctionName,
|
||||
FunctionArgs = meta?.FunctionArgs,
|
||||
ToolCallId = meta?.ToolCallId,
|
||||
ReasoningContent = dialog.ReasoningContent ?? meta?.ReasoningContent,
|
||||
RichContent = richContent,
|
||||
SecondaryContent = secondaryContent,
|
||||
SecondaryRichContent = secondaryRichContent,
|
||||
|
|
@ -109,6 +110,7 @@ public class ConversationStorage : IConversationStorage
|
|||
FunctionName = dialog.FunctionName,
|
||||
FunctionArgs = dialog.FunctionArgs,
|
||||
ToolCallId = dialog.ToolCallId,
|
||||
ReasoningContent = dialog.ReasoningContent,
|
||||
CreatedTime = dialog.CreatedAt
|
||||
};
|
||||
|
||||
|
|
@ -120,6 +122,7 @@ public class ConversationStorage : IConversationStorage
|
|||
MetaData = meta,
|
||||
Content = dialog.Content,
|
||||
SecondaryContent = dialog.SecondaryContent,
|
||||
ReasoningContent = dialog.ReasoningContent,
|
||||
Payload = dialog.Payload
|
||||
};
|
||||
}
|
||||
|
|
@ -135,6 +138,7 @@ public class ConversationStorage : IConversationStorage
|
|||
MessageLabel = dialog.MessageLabel,
|
||||
SenderId = dialog.SenderId,
|
||||
FunctionName = dialog.FunctionName,
|
||||
ReasoningContent = dialog.ReasoningContent,
|
||||
CreatedTime = dialog.CreatedAt
|
||||
};
|
||||
|
||||
|
|
@ -150,6 +154,7 @@ public class ConversationStorage : IConversationStorage
|
|||
SecondaryContent = dialog.SecondaryContent,
|
||||
RichContent = richContent,
|
||||
SecondaryRichContent = secondaryRichContent,
|
||||
ReasoningContent = dialog.ReasoningContent,
|
||||
Payload = dialog.Payload
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ public partial class RoutingService
|
|||
message.ToolCallId = response.ToolCallId;
|
||||
message.FunctionName = response.FunctionName;
|
||||
message.FunctionArgs = response.FunctionArgs;
|
||||
message.ReasoningContent = response.ReasoningContent;
|
||||
message.Indication = response.Indication;
|
||||
message.CurrentAgentId = agent.Id;
|
||||
message.IsStreaming = response.IsStreaming;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable SCME0001
|
||||
using BotSharp.Abstraction.Conversations.Enums;
|
||||
using BotSharp.Abstraction.Files;
|
||||
using BotSharp.Abstraction.Files.Models;
|
||||
|
|
@ -10,6 +11,7 @@ using BotSharp.Core.MessageHub;
|
|||
using BotSharp.Plugin.DeepSeek.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
using System.ClientModel.Primitives;
|
||||
|
||||
namespace BotSharp.Plugin.DeepSeekAI.Providers.Chat;
|
||||
|
||||
|
|
@ -62,7 +64,8 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
ToolCallId = toolCall?.Id,
|
||||
FunctionName = toolCall?.FunctionName,
|
||||
FunctionArgs = toolCall?.FunctionArguments?.ToString(),
|
||||
RenderedInstruction = string.Join("\r\n", renderedInstructions)
|
||||
RenderedInstruction = string.Join("\r\n", renderedInstructions),
|
||||
ReasoningContent = ExtractReasoningContent(value)
|
||||
};
|
||||
|
||||
// Somethings LLM will generate a function name with agent name.
|
||||
|
|
@ -78,6 +81,7 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
CurrentAgentId = agent.Id,
|
||||
MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty,
|
||||
RenderedInstruction = string.Join("\r\n", renderedInstructions),
|
||||
ReasoningContent = ExtractReasoningContent(value),
|
||||
Annotations = value.Annotations?.Select(x => new ChatAnnotation
|
||||
{
|
||||
Title = x.WebResourceTitle,
|
||||
|
|
@ -131,7 +135,8 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
var msg = new RoleDialogModel(AgentRole.Assistant, text)
|
||||
{
|
||||
CurrentAgentId = agent.Id,
|
||||
RenderedInstruction = string.Join("\r\n", renderedInstructions)
|
||||
RenderedInstruction = string.Join("\r\n", renderedInstructions),
|
||||
ReasoningContent = ExtractReasoningContent(value)
|
||||
};
|
||||
|
||||
var tokenUsage = response?.Value?.Usage;
|
||||
|
|
@ -163,7 +168,8 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
ToolCallId = toolCall?.Id,
|
||||
FunctionName = toolCall?.FunctionName,
|
||||
FunctionArgs = toolCall?.FunctionArguments?.ToString(),
|
||||
RenderedInstruction = string.Join("\r\n", renderedInstructions)
|
||||
RenderedInstruction = string.Join("\r\n", renderedInstructions),
|
||||
ReasoningContent = ExtractReasoningContent(value)
|
||||
};
|
||||
|
||||
// Somethings LLM will generate a function name with agent name.
|
||||
|
|
@ -183,6 +189,7 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
CurrentAgentId = agent.Id,
|
||||
MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty,
|
||||
RenderedInstruction = string.Join("\r\n", renderedInstructions),
|
||||
ReasoningContent = ExtractReasoningContent(value),
|
||||
Annotations = value.Annotations?.Select(x => new ChatAnnotation
|
||||
{
|
||||
Title = x.WebResourceTitle,
|
||||
|
|
@ -228,6 +235,7 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
using var textStream = new RealtimeTextStream();
|
||||
var toolCalls = new List<StreamingChatToolCallUpdate>();
|
||||
ChatTokenUsage? tokenUsage = null;
|
||||
var reasoningContentBuilder = new System.Text.StringBuilder();
|
||||
|
||||
var responseMessage = new RoleDialogModel(AgentRole.Assistant, string.Empty)
|
||||
{
|
||||
|
|
@ -244,6 +252,14 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
toolCalls.AddRange(choice.ToolCallUpdates);
|
||||
}
|
||||
|
||||
// DeepSeek thinking mode streams the chain-of-thought in `reasoning_content`
|
||||
// (delta level). OpenAI SDK 2.12 keeps it as an unknown property in the JsonPatch.
|
||||
var reasoningDelta = ReadStreamingReasoningContent(choice);
|
||||
if (!string.IsNullOrEmpty(reasoningDelta))
|
||||
{
|
||||
reasoningContentBuilder.Append(reasoningDelta);
|
||||
}
|
||||
|
||||
if (!choice.ContentUpdate.IsNullOrEmpty())
|
||||
{
|
||||
var text = choice.ContentUpdate[0]?.Text ?? string.Empty;
|
||||
|
|
@ -284,7 +300,8 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
MessageId = messageId,
|
||||
ToolCallId = toolCallId,
|
||||
FunctionName = functionName,
|
||||
FunctionArgs = functionArgument
|
||||
FunctionArgs = functionArgument,
|
||||
ReasoningContent = reasoningContentBuilder.Length > 0 ? reasoningContentBuilder.ToString() : null
|
||||
};
|
||||
}
|
||||
else if (choice.FinishReason.HasValue)
|
||||
|
|
@ -296,7 +313,8 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
{
|
||||
CurrentAgentId = agent.Id,
|
||||
MessageId = messageId,
|
||||
IsStreaming = true
|
||||
IsStreaming = true,
|
||||
ReasoningContent = reasoningContentBuilder.Length > 0 ? reasoningContentBuilder.ToString() : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -332,6 +350,67 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
_model = model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts <c>reasoning_content</c> (DeepSeek thinking mode) from a non-streaming
|
||||
/// <see cref="ChatCompletion"/>. The OpenAI SDK keeps the unknown property in the
|
||||
/// message's JsonPatch, addressable at <c>$.choices[0].message.reasoning_content</c>.
|
||||
/// </summary>
|
||||
private static string? ExtractReasoningContent(ChatCompletion value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return DecodePatchString(value.Patch.GetJson("$.choices[0].message.reasoning_content"u8));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never fail the chat because of a reasoning_content extraction issue.
|
||||
System.Diagnostics.Debug.WriteLine($"ExtractReasoningContent failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts one streaming chunk of <c>reasoning_content</c> from a
|
||||
/// <see cref="StreamingChatCompletionUpdate"/>. Unknown delta properties land in the
|
||||
/// update's JsonPatch under <c>$.choices[0].delta.reasoning_content</c>.
|
||||
/// </summary>
|
||||
private static string? ReadStreamingReasoningContent(StreamingChatCompletionUpdate choice)
|
||||
{
|
||||
if (choice == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return DecodePatchString(choice.Patch.GetJson("$.choices[0].delta.reasoning_content"u8));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never fail the chat because of a reasoning_content extraction issue.
|
||||
System.Diagnostics.Debug.WriteLine($"ReadStreamingReasoningContent failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? DecodePatchString(BinaryData data)
|
||||
{
|
||||
var bytes = data.ToArray();
|
||||
if (bytes.Length >= 2 && bytes[0] == (byte)'"' && bytes[^1] == (byte)'"')
|
||||
{
|
||||
return System.Text.Encoding.UTF8.GetString(bytes, 1, bytes.Length - 2);
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
protected (string, IEnumerable<ChatMessage>, ChatCompletionOptions) PrepareOptions(Agent agent, List<RoleDialogModel> conversations)
|
||||
{
|
||||
var agentService = _services.GetRequiredService<IAgentService>();
|
||||
|
|
@ -394,10 +473,13 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
{
|
||||
if (message.Role == AgentRole.Function)
|
||||
{
|
||||
messages.Add(new AssistantChatMessage(new List<ChatToolCall>
|
||||
var assistantToolMsg = new AssistantChatMessage(new List<ChatToolCall>
|
||||
{
|
||||
ChatToolCall.CreateFunctionToolCall(message.ToolCallId.IfNullOrEmptyAs(message.FunctionName), message.FunctionName, BinaryData.FromString(message.FunctionArgs ?? "{}"))
|
||||
}));
|
||||
});
|
||||
EchoReasoningContent(assistantToolMsg, message);
|
||||
|
||||
messages.Add(assistantToolMsg);
|
||||
|
||||
messages.Add(new ToolChatMessage(message.ToolCallId.IfNullOrEmptyAs(message.FunctionName), message.LlmContent));
|
||||
}
|
||||
|
|
@ -423,7 +505,10 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
{
|
||||
CollectMessageContentParts(contentParts, message.Files, imageDetailLevel);
|
||||
}
|
||||
messages.Add(new AssistantChatMessage(contentParts));
|
||||
var assistantMsg = new AssistantChatMessage(contentParts);
|
||||
EchoReasoningContent(assistantMsg, message);
|
||||
|
||||
messages.Add(assistantMsg);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -431,6 +516,24 @@ public class ChatCompletionProvider : IChatCompletion
|
|||
return (prompt, messages, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeepSeek requires the <c>reasoning_content</c> of previous assistant messages to be
|
||||
/// echoed back in every follow-up request that carries tools (otherwise 400
|
||||
/// "The reasoning_content ... must be passed back"). The OpenAI SDK serializes it via
|
||||
/// the message's JsonPatch (experimental SCME0001, suppressed by the file-level pragma).
|
||||
/// </summary>
|
||||
private static void EchoReasoningContent(AssistantChatMessage assistantMessage, RoleDialogModel dialog)
|
||||
{
|
||||
var reasoning = dialog.ReasoningContent;
|
||||
if (string.IsNullOrWhiteSpace(reasoning) || assistantMessage == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ref var patch = ref assistantMessage.Patch;
|
||||
patch.Set("$.reasoning_content"u8, reasoning);
|
||||
}
|
||||
|
||||
private void CollectMessageContentParts(List<ChatMessageContentPart> contentParts, List<BotSharpFile> files, ChatImageDetailLevel imageDetailLevel)
|
||||
{
|
||||
foreach (var file in files)
|
||||
|
|
|
|||
|
|
@ -105,10 +105,10 @@ public partial class ImageCompletionProvider : IImageCompletion
|
|||
switch (value)
|
||||
{
|
||||
case "low":
|
||||
retQuality = GeneratedImageQuality.Low;
|
||||
retQuality = GeneratedImageQuality.LowQuality;
|
||||
break;
|
||||
case "medium":
|
||||
retQuality = GeneratedImageQuality.Medium;
|
||||
retQuality = GeneratedImageQuality.MediumQuality;
|
||||
break;
|
||||
case "high":
|
||||
retQuality = GeneratedImageQuality.High;
|
||||
|
|
|
|||
Loading…
Reference in a new issue