diff --git a/TelegramSearchBot.Common/Model/AI/LlmContinuationSnapshot.cs b/TelegramSearchBot.Common/Model/AI/LlmContinuationSnapshot.cs index ddec625f..5c678fd3 100644 --- a/TelegramSearchBot.Common/Model/AI/LlmContinuationSnapshot.cs +++ b/TelegramSearchBot.Common/Model/AI/LlmContinuationSnapshot.cs @@ -16,6 +16,12 @@ public class SerializedChatMessage { /// The text content of the message /// public string Content { get; set; } = null!; + + /// + /// The reasoning content for thinking mode models (e.g., Kimi-thinking-preview, QwQ). + /// This field must be passed back to the API in subsequent requests to avoid HTTP 400 errors. + /// + public string? ReasoningContent { get; set; } } /// diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs index 8044cda4..ab34380a 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs @@ -967,6 +967,7 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( if (cancellationToken.IsCancellationRequested) throw new TaskCanceledException(); var contentBuilder = new StringBuilder(); + var reasoningContentBuilder = new StringBuilder(); var toolCallAccumulators = new Dictionary(); ChatFinishReason? finishReason = null; @@ -985,6 +986,12 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( } } + // Accumulate reasoning content for thinking mode models (e.g., Kimi-thinking-preview) + var reasoningUpdate = GetStreamingReasoningContent(update); + if (!string.IsNullOrEmpty(reasoningUpdate)) { + reasoningContentBuilder.Append(reasoningUpdate); + } + // Accumulate tool call updates foreach (var toolCallUpdate in update.ToolCallUpdates ?? Enumerable.Empty()) { int index = toolCallUpdate.Index; @@ -1006,6 +1013,7 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( } string responseText = contentBuilder.ToString().Trim(); + string reasoningContent = reasoningContentBuilder.ToString().Trim(); // Check if this is a tool call response if (finishReason == ChatFinishReason.ToolCalls && toolCallAccumulators.Any()) { @@ -1025,6 +1033,10 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( if (!string.IsNullOrWhiteSpace(responseText)) { assistantMessage = new AssistantChatMessage(chatToolCalls) { Content = { ChatMessageContentPart.CreateTextPart(responseText) } }; } + // Set reasoning content for thinking mode models + if (!string.IsNullOrEmpty(reasoningContent)) { + SetAssistantReasoningContent(assistantMessage, reasoningContent); + } providerHistory.Add(assistantMessage); var toolIndicators = new StringBuilder(); @@ -1066,7 +1078,11 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( } else { // Not a tool call - regular text response if (!string.IsNullOrWhiteSpace(responseText)) { - providerHistory.Add(new AssistantChatMessage(responseText)); + var assistantMsg = new AssistantChatMessage(responseText); + if (!string.IsNullOrEmpty(reasoningContent)) { + SetAssistantReasoningContent(assistantMsg, reasoningContent); + } + providerHistory.Add(assistantMsg); } yield break; } @@ -1328,6 +1344,7 @@ public static List SerializeProviderHistory(List SerializeProviderHistory(List p.Text) ?? Enumerable.Empty()); + // Try to get reasoning content from the assistant message + // OpenAI SDK stores reasoning content in a separate property + reasoningContent = GetAssistantReasoningContent(assistantMsg); } else if (msg is UserChatMessage userMsg) { role = "user"; content = string.Join("", userMsg.Content?.Select(p => p.Text) ?? Enumerable.Empty()); @@ -1343,11 +1363,61 @@ public static List SerializeProviderHistory(List + /// Extract reasoning_content from AssistantChatMessage if available. + /// For thinking mode models, the reasoning process is returned separately. + /// + private static string? GetAssistantReasoningContent(AssistantChatMessage assistantMsg) { + // Try to access reasoning content - OpenAI Chat SDK may store it in various ways + // The reasoning_content is typically available via reflection or specific properties + try { + // Check for Reasoning property via reflection + var reasoningProp = assistantMsg.GetType().GetProperty("Reasoning"); + if (reasoningProp != null) { + var value = reasoningProp.GetValue(assistantMsg); + if (value is string reasoning && !string.IsNullOrEmpty(reasoning)) { + return reasoning; + } + } + } catch { + // Reflection failed, return null + } + return null; + } + + /// + /// Extract reasoning_content from streaming update for thinking mode models. + /// Uses reflection to access SDK internals. + /// + private static string? GetStreamingReasoningContent(StreamingChatCompletionUpdate update) { + try { + // Try ReasoningContentUpdate property (OpenAI SDK for thinking models) + var reasoningProp = update.GetType().GetProperty("ReasoningContentUpdate"); + if (reasoningProp != null) { + var value = reasoningProp.GetValue(update); + if (value is string reasoning && !string.IsNullOrEmpty(reasoning)) { + return reasoning; + } + } + // Fallback: try Reasoning property + var fallbackProp = update.GetType().GetProperty("Reasoning"); + if (fallbackProp != null) { + var value = fallbackProp.GetValue(update); + if (value is string fallback && !string.IsNullOrEmpty(fallback)) { + return fallback; + } + } + } catch { + // Reflection failed + } + return null; + } + /// /// Deserialize portable format back to OpenAI ChatMessage list. /// @@ -1361,7 +1431,12 @@ public static List DeserializeProviderHistory(List DeserializeProviderHistory(List + /// Set reasoning_content on AssistantChatMessage for thinking mode models. + /// Uses reflection since OpenAI SDK doesn't have a public setter. + /// + private static void SetAssistantReasoningContent(AssistantChatMessage msg, string reasoningContent) { + try { + var prop = msg.GetType().GetProperty("Reasoning"); + if (prop != null && prop.CanWrite) { + prop.SetValue(msg, reasoningContent); + } + } catch { + // Reflection failed, ignore + } + } + public async Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel) {