From bbbcdddc910fb8db401da72cd474ae5de9ac341b Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:37:43 +0200 Subject: [PATCH 1/3] Add per-answer thinking disclosure --- .../Assistants/I18N/allTexts.lua | 9 ++ .../Chat/ContentBlockComponent.razor | 25 ++++ .../Chat/ContentBlockComponent.razor.cs | 14 +- app/MindWork AI Studio/Chat/ContentText.cs | 140 +++++++++++++++++- .../plugin.lua | 9 ++ .../plugin.lua | 9 ++ .../Provider/Anthropic/AnthropicResponse.cs | 9 +- .../Anthropic/AnthropicToolCallingAdapter.cs | 3 +- .../Provider/Anthropic/Delta.cs | 3 +- .../Provider/Anthropic/ResponseStreamLine.cs | 11 +- .../Provider/BaseProvider.cs | 8 +- .../Provider/ContentStreamChunk.cs | 9 +- .../Provider/Fireworks/Delta.cs | 12 +- .../Provider/Fireworks/ResponseStreamLine.cs | 4 +- .../OpenAI/AssistantToolCallMessage.cs | 6 + .../Provider/OpenAI/ChatCompletionDelta.cs | 9 ++ .../OpenAI/ChatCompletionDeltaStreamLine.cs | 4 +- .../OpenAI/ChatCompletionResponseMessage.cs | 6 + .../ChatCompletionToolCallingAdapter.cs | 5 +- .../OpenAI/ResponsesDeltaStreamLine.cs | 8 +- .../Provider/OpenAI/ResponsesResponse.cs | 5 + .../OpenAI/ResponsesToolCallingAdapter.cs | 3 +- .../Provider/OpenAI/ThinkingContent.cs | 46 ++++++ .../Provider/Perplexity/Delta.cs | 12 +- .../Provider/Perplexity/ResponseStreamLine.cs | 4 +- .../Tools/AIJobs/AIJobService.cs | 10 +- .../Harness/ToolCallingLoop.cs | 10 +- .../Harness/ToolCallingRound.cs | 3 +- .../wwwroot/changelog/v26.9.1.md | 1 + 29 files changed, 360 insertions(+), 37 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 82e9864fa..499994b75 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3220,6 +3220,12 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T3768991250"] = "User" -- AI UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T601166687"] = "AI" +-- Show thinking +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1121977572"] = "Show thinking" + +-- Thinking +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1182941917"] = "Thinking" + -- Edit Message UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1183581066"] = "Edit Message" @@ -3301,6 +3307,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked -- Do you really want to regenerate this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?" +-- Hide thinking +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3979442719"] = "Hide thinking" + -- Remove Message UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message" diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index edab5ac68..95b450b73 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -31,6 +31,23 @@ } + @if (this.HasThinking) + { + + + + + @T("Thinking") + + + + + } @@ -193,6 +210,14 @@ } + @if (this.HasThinking && this.showThinking) + { + + @T("Thinking") + @textContent.Thinking + + } + if (textContent.InitialRemoteWait) { diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index b2ec2babb..84fdce113 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -127,6 +127,7 @@ public partial class ContentBlockComponent : MSGComponentBase private bool hasActiveMathContainer; private bool isDisposed; private bool showToolTrace; + private bool showThinking; private readonly HashSet expandedToolInvocations = []; /// @@ -139,6 +140,10 @@ public partial class ContentBlockComponent : MSGComponentBase /// private bool CanExport => this.Content is { InitialRemoteWait: false, IsStreaming: false } && this.Content.TryGetMarkdownText(out _); + private bool HasThinking => this.Role is ChatRole.AI && + this.Content is ContentText { Thinking: var thinking } && + !string.IsNullOrWhiteSpace(thinking); + /// /// The tables this block holds so that the export menu can offer each of them. /// @@ -305,11 +310,14 @@ private int CreateRenderHash() var textValue = text.Text; hash.Add(textValue.Length); hash.Add(textValue.GetHashCode(StringComparison.Ordinal)); + hash.Add(text.Thinking.Length); + hash.Add(text.Thinking.GetHashCode(StringComparison.Ordinal)); hash.Add(text.Sources.Count); hash.Add(text.ToolInvocations.Count); hash.Add(text.ToolRuntimeStatus.IsRunning); hash.Add(text.ToolRuntimeStatus.Message); hash.Add(this.showToolTrace); + hash.Add(this.showThinking); hash.Add(this.expandedToolInvocations.Count); foreach (var expandedInvocation in this.expandedToolInvocations.Order()) hash.Add(expandedInvocation); @@ -380,6 +388,10 @@ private string GetToolTraceTooltip() private void ToggleToolTrace() => this.showToolTrace = !this.showToolTrace; + private void ToggleThinking() => this.showThinking = !this.showThinking; + + private string GetThinkingTooltip() => this.showThinking ? this.T("Hide thinking") : this.T("Show thinking"); + private bool IsToolInvocationExpanded(int order) => this.expandedToolInvocations.Contains(order); private void ToggleToolInvocation(int order) @@ -832,4 +844,4 @@ protected override async ValueTask DisposeResourcesAsync() await this.DisposeMathContainerIfNeededAsync(); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index 0640b6582..c4f37315a 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -16,6 +16,9 @@ namespace AIStudio.Chat; /// public sealed class ContentText : IContent { + private const string OPEN_THINK_TAG = ""; + private const string CLOSE_THINK_TAG = ""; + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText)); @@ -26,6 +29,12 @@ public sealed class ContentText : IContent /// private static readonly TimeSpan MIN_TIME = TimeSpan.FromSeconds(3); + [JsonIgnore] + private ThinkTagStreamState thinkTagStreamState; + + [JsonIgnore] + private readonly StringBuilder thinkTagBuffer = new(); + #region Implementation of IContent /// @@ -122,11 +131,8 @@ await Task.Run(async () => this.InitialRemoteWait = false; this.IsStreaming = true; - // Add the response to the text: - this.Text += contentStreamChunk; - - // Merge the sources: - this.Sources.MergeSources(contentStreamChunk.Sources); + // Add the response to the content: + this.ApplyStreamChunk(contentStreamChunk); // Notify the UI that the content has changed, // depending on the energy saving mode: @@ -160,7 +166,7 @@ await Task.Run(async () => } finally { - this.Text = this.Text.RemoveThinkTags().Trim(); + this.FinalizeStreamContent(); // Inform the UI that the streaming is done: await this.StreamingDone(); @@ -253,6 +259,7 @@ private async Task CheckSelectedModelAvailability(IProvider provider, Mode public IContent DeepClone() => new ContentText { Text = this.Text, + Thinking = this.Thinking, InitialRemoteWait = this.InitialRemoteWait, IsStreaming = this.IsStreaming, Sources = [..this.Sources], @@ -407,4 +414,123 @@ public async Task PrepareTextContentForAI() /// The text content. /// public string Text { get; set; } = string.Empty; -} \ No newline at end of file + + /// + /// Human-readable thinking content exposed by the provider. + /// + public string Thinking { get; set; } = string.Empty; + + /// + /// Applies one provider stream chunk to this content. + /// + public void ApplyStreamChunk(ContentStreamChunk chunk) + { + if (!string.IsNullOrEmpty(chunk.Thinking)) + this.Thinking += chunk.Thinking; + + if (!string.IsNullOrEmpty(chunk.Content)) + this.ApplyAnswerChunk(chunk.Content); + + this.Sources.MergeSources(chunk.Sources); + } + + /// + /// Completes parsing of provider content and normalizes the displayed values. + /// + public void FinalizeStreamContent() + { + switch (this.thinkTagStreamState) + { + case ThinkTagStreamState.UNDECIDED: + this.Text += this.thinkTagBuffer; + break; + + case ThinkTagStreamState.THINKING: + this.Thinking += this.thinkTagBuffer; + break; + } + + this.thinkTagBuffer.Clear(); + this.thinkTagStreamState = ThinkTagStreamState.ANSWER; + this.Text = this.Text.Trim(); + this.Thinking = this.Thinking.Trim(); + } + + private void ApplyAnswerChunk(string content) + { + if (this.thinkTagStreamState is ThinkTagStreamState.UNDECIDED && this.Text.Length > 0) + this.thinkTagStreamState = ThinkTagStreamState.ANSWER; + + switch (this.thinkTagStreamState) + { + case ThinkTagStreamState.UNDECIDED: + this.thinkTagBuffer.Append(content); + var undecidedContent = this.thinkTagBuffer.ToString(); + if (undecidedContent.Length < OPEN_THINK_TAG.Length && + OPEN_THINK_TAG.StartsWith(undecidedContent, StringComparison.Ordinal)) + return; + + if (!undecidedContent.StartsWith(OPEN_THINK_TAG, StringComparison.Ordinal)) + { + this.Text += undecidedContent; + this.thinkTagBuffer.Clear(); + this.thinkTagStreamState = ThinkTagStreamState.ANSWER; + return; + } + + this.thinkTagBuffer.Clear(); + this.thinkTagStreamState = ThinkTagStreamState.THINKING; + this.ApplyThinkingTagContent(undecidedContent[OPEN_THINK_TAG.Length..]); + return; + + case ThinkTagStreamState.THINKING: + this.ApplyThinkingTagContent(content); + return; + + case ThinkTagStreamState.ANSWER: + this.Text += content; + return; + } + } + + private void ApplyThinkingTagContent(string content) + { + this.thinkTagBuffer.Append(content); + var thinkingContent = this.thinkTagBuffer.ToString(); + var closeTagIndex = thinkingContent.IndexOf(CLOSE_THINK_TAG, StringComparison.Ordinal); + if (closeTagIndex >= 0) + { + this.Thinking += thinkingContent[..closeTagIndex]; + this.Text += thinkingContent[(closeTagIndex + CLOSE_THINK_TAG.Length)..]; + this.thinkTagBuffer.Clear(); + this.thinkTagStreamState = ThinkTagStreamState.ANSWER; + return; + } + + var pendingLength = GetMarkerPrefixSuffixLength(thinkingContent, CLOSE_THINK_TAG); + var completedLength = thinkingContent.Length - pendingLength; + if (completedLength <= 0) + return; + + this.Thinking += thinkingContent[..completedLength]; + this.thinkTagBuffer.Clear(); + this.thinkTagBuffer.Append(thinkingContent.AsSpan(completedLength)); + } + + private static int GetMarkerPrefixSuffixLength(string content, string marker) + { + var maximumLength = Math.Min(content.Length, marker.Length - 1); + for (var length = maximumLength; length > 0; length--) + if (content.AsSpan(content.Length - length).SequenceEqual(marker.AsSpan(0, length))) + return length; + + return 0; + } + + private enum ThinkTagStreamState + { + UNDECIDED, + THINKING, + ANSWER, + } +} diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 60ed776d8..04ec89821 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -3222,6 +3222,12 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T3768991250"] = "Benutzer" -- AI UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T601166687"] = "KI" +-- Show thinking +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1121977572"] = "Denkprozess anzeigen" + +-- Thinking +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1182941917"] = "Denkprozess" + -- Edit Message UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1183581066"] = "Nachricht bearbeiten" @@ -3303,6 +3309,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blockie -- Do you really want to regenerate this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Möchten Sie diese Nachricht wirklich neu generieren?" +-- Hide thinking +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3979442719"] = "Denkprozess ausblenden" + -- Remove Message UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Nachricht entfernen" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 13998081a..b7297e90b 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -3222,6 +3222,12 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T3768991250"] = "User" -- AI UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T601166687"] = "AI" +-- Show thinking +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1121977572"] = "Show thinking" + +-- Thinking +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1182941917"] = "Thinking" + -- Edit Message UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1183581066"] = "Edit Message" @@ -3303,6 +3309,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked -- Do you really want to regenerate this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?" +-- Hide thinking +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3979442719"] = "Hide thinking" + -- Remove Message UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message" diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs index b94d23cb7..c320c050c 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs @@ -36,6 +36,13 @@ public string GetTextOutput() => string.Concat(this.Content .Where(x => ReadString(x, "type").Equals("text", StringComparison.Ordinal)) .Select(x => ReadString(x, "text"))); + /// + /// The human-readable thinking the model returned, with redacted blocks omitted. + /// + public string GetThinkingOutput() => string.Concat(this.Content + .Where(x => ReadString(x, "type").Equals("thinking", StringComparison.Ordinal)) + .Select(x => ReadString(x, "thinking"))); + private static string ReadString(JsonElement item, string propertyName) { if (item.ValueKind is not JsonValueKind.Object || @@ -45,4 +52,4 @@ private static string ReadString(JsonElement item, string propertyName) return property.GetString() ?? string.Empty; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs index 9843b3629..c0f6ce5b1 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs @@ -55,6 +55,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList new ToolCallingRequestedCall( toolUse.Id, @@ -85,4 +86,4 @@ public void RecordToolResult(string callId, string content, bool isError = false Content = content, IsError = isError, }); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Anthropic/Delta.cs b/app/MindWork AI Studio/Provider/Anthropic/Delta.cs index 84fb06bea..05f02d7db 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/Delta.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/Delta.cs @@ -6,4 +6,5 @@ namespace AIStudio.Provider.Anthropic; /// /// The type of the delta. /// The text of the delta. -public readonly record struct Delta(string Type, string Text); \ No newline at end of file +/// The human-readable thinking delta. +public readonly record struct Delta(string Type, string Text, string Thinking); diff --git a/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs index 195f164c0..ef47ccbe5 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs @@ -10,10 +10,15 @@ namespace AIStudio.Provider.Anthropic; public readonly record struct ResponseStreamLine(string Type, int Index, Delta Delta) : IResponseStreamLine { /// - public bool ContainsContent() => this != default && !string.IsNullOrWhiteSpace(this.Delta.Text); + public bool ContainsContent() => this != default && + (!string.IsNullOrEmpty(this.Delta.Text) || !string.IsNullOrEmpty(this.Delta.Thinking)); /// - public ContentStreamChunk GetContent() => new(this.Delta.Text, []); + public ContentStreamChunk GetContent() => this.Delta.Type switch + { + "thinking_delta" => new(string.Empty, this.Delta.Thinking, []), + _ => new(this.Delta.Text, string.Empty, []), + }; #region Implementation of IAnnotationStreamLine @@ -29,4 +34,4 @@ public readonly record struct ResponseStreamLine(string Type, int Index, Delta D public IList GetSources() => []; #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 3ff4de26f..445228a44 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1048,6 +1048,12 @@ protected async IAsyncEnumerable StreamResponsesInternal parameters, stri return true; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs index c6b2e205c..a6c7347e6 100644 --- a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs +++ b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs @@ -4,13 +4,18 @@ namespace AIStudio.Provider; /// A chunk of content from a content stream, along with its associated sources. /// /// The text content of the chunk. +/// The provider-exposed thinking content of the chunk. /// The list of sources associated with the chunk. -public sealed record ContentStreamChunk(string Content, IList Sources) +public sealed record ContentStreamChunk(string Content, string Thinking, IList Sources) { + public ContentStreamChunk(string content, IList sources) : this(content, string.Empty, sources) + { + } + /// /// Implicit conversion to string. /// /// The content stream chunk. /// The text content of the chunk. public static implicit operator string(ContentStreamChunk chunk) => chunk.Content; -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Fireworks/Delta.cs b/app/MindWork AI Studio/Provider/Fireworks/Delta.cs index 3c53684bc..1b4d0741d 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/Delta.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/Delta.cs @@ -1,7 +1,17 @@ +using System.Text.Json; + +using AIStudio.Provider.OpenAI; + namespace AIStudio.Provider.Fireworks; /// /// The delta text of a choice. /// /// The content of the delta text. -public readonly record struct Delta(string Content); \ No newline at end of file +/// OpenAI-compatible reasoning content. +/// OpenRouter-compatible reasoning content. +/// Structured reasoning details. +public readonly record struct Delta(string Content, string? ReasoningContent, string? Reasoning, IList? ReasoningDetails) +{ + public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails); +} diff --git a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs index 25e35f829..a0869f3ee 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs @@ -14,7 +14,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint public bool ContainsContent() => this != default && this.Choices.Count > 0; /// - public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, []); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.Choices[0].Delta.Thinking, []); #region Implementation of IAnnotationStreamLine @@ -29,4 +29,4 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint public IList GetSources() => []; #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs index cd4048789..45bbae2a2 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs @@ -12,5 +12,11 @@ public sealed record AssistantToolCallMessage : IMessageBase [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ReasoningContent { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Reasoning { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? ReasoningDetails { get; init; } + public IList ToolCalls { get; init; } = []; } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs index 9f6f6cfcc..517b81191 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs @@ -13,4 +13,13 @@ public sealed record ChatCompletionDelta [JsonIgnore] public string Content => ChatCompletionContent.GetText(this.RawContent) ?? string.Empty; + + public string? ReasoningContent { get; init; } + + public string? Reasoning { get; init; } + + public IList? ReasoningDetails { get; init; } + + [JsonIgnore] + public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails); } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs index 1db13ba9d..7b686ac3a 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs @@ -19,7 +19,7 @@ public ChatCompletionDeltaStreamLine() : this(string.Empty, string.Empty, 0, str public bool ContainsContent() => this.Choices.Count > 0; /// - public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, []); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.Choices[0].Delta.Thinking, []); #region Implementation of IAnnotationStreamLine @@ -42,4 +42,4 @@ public ChatCompletionDeltaStreamLine() : this(string.Empty, string.Empty, 0, str public IList GetSources() => []; #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs index 40b20e788..4ec59e9e3 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs @@ -15,5 +15,11 @@ public sealed record ChatCompletionResponseMessage public string? ReasoningContent { get; init; } + public string? Reasoning { get; init; } + + public IList? ReasoningDetails { get; init; } + public IList? ToolCalls { get; init; } + + public string GetThinkingOutput() => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails); } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs index 594efc43f..c525eff26 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs @@ -72,6 +72,7 @@ public sealed class ChatCompletionToolCallingAdapter( return new ToolCallingRound( responseChoice.Message.Content ?? string.Empty, + responseChoice.Message.GetThinkingOutput(), preparedCalls .Select(x => new ToolCallingRequestedCall(x.ToolCall.Id!, x.ToolCall.Function!.Name!, x.ToolCall.Function!.Arguments!, x.IsValid)) .ToList(), @@ -83,6 +84,8 @@ public void RecordAssistantTurn() => this.internalMessages.Add(new AssistantTool { Content = this.lastResponseMessage?.RawContent, ReasoningContent = this.lastResponseMessage?.ReasoningContent, + Reasoning = this.lastResponseMessage?.Reasoning, + ReasoningDetails = this.lastResponseMessage?.ReasoningDetails, ToolCalls = this.lastToolCalls, }); @@ -168,4 +171,4 @@ private List PrepareToolCalls(IEnumerable this.Delta is not null; /// - public ContentStreamChunk GetContent() => new(this.Delta ?? string.Empty, this.GetSources()); + public ContentStreamChunk GetContent() => this.Type switch + { + "response.reasoning_summary_text.delta" or "response.reasoning_text.delta" => new(string.Empty, this.Delta ?? string.Empty, this.GetSources()), + _ => new(this.Delta ?? string.Empty, string.Empty, this.GetSources()), + }; // // Please note that there are multiple options where LLM providers might stream sources: @@ -36,4 +40,4 @@ public record ResponsesDeltaStreamLine( public IList GetSources() => []; #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs index 285bca00e..f0266ea2c 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs @@ -42,6 +42,11 @@ public string GetTextOutput() })); } + public string GetThinkingOutput() => string.Concat(this.Output + .Where(x => ReadString(x, "type").Equals("reasoning", StringComparison.Ordinal)) + .SelectMany(x => ReadArrayItems(x, "summary").Concat(ReadArrayItems(x, "content"))) + .Select(x => ReadString(x, "text"))); + public IReadOnlyList GetSources() => this.Output .Where(x => ReadString(x, "type").Equals("message", StringComparison.Ordinal)) .SelectMany(ReadContentItems) diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs index 014865579..5d30a6234 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs @@ -57,6 +57,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b this.lastResponse = response; return new ToolCallingRound( response.GetTextOutput(), + response.GetThinkingOutput(), response.GetFunctionCalls() .Select(call => new ToolCallingRequestedCall( call.CallId ?? string.Empty, @@ -102,4 +103,4 @@ private static IList BuildEffectiveProviderTools(IList providerT .Concat(runnableTools.Select(x => (object)ProviderToolAdapters.ToResponsesTool(x.Definition))) .ToList(); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs b/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs new file mode 100644 index 000000000..114258cd7 --- /dev/null +++ b/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs @@ -0,0 +1,46 @@ +using System.Text.Json; + +namespace AIStudio.Provider.OpenAI; + +/// +/// Reads human-readable thinking from OpenAI-compatible response fields. +/// +internal static class ThinkingContent +{ + public static string Get(string? reasoningContent, string? reasoning, IEnumerable? reasoningDetails) + { + if (!string.IsNullOrWhiteSpace(reasoningContent)) + return reasoningContent; + + if (!string.IsNullOrWhiteSpace(reasoning)) + return reasoning; + + if (reasoningDetails is null) + return string.Empty; + + return string.Concat(reasoningDetails.Select(GetVisibleDetailText)); + } + + private static string GetVisibleDetailText(JsonElement detail) + { + if (detail.ValueKind is not JsonValueKind.Object || + !detail.TryGetProperty("type", out var typeProperty) || + typeProperty.ValueKind is not JsonValueKind.String) + return string.Empty; + + return typeProperty.GetString() switch + { + "reasoning.text" => ReadString(detail, "text"), + "reasoning.summary" => ReadString(detail, "summary"), + _ => string.Empty, + }; + } + + private static string ReadString(JsonElement item, string propertyName) + { + if (!item.TryGetProperty(propertyName, out var property) || property.ValueKind is not JsonValueKind.String) + return string.Empty; + + return property.GetString() ?? string.Empty; + } +} diff --git a/app/MindWork AI Studio/Provider/Perplexity/Delta.cs b/app/MindWork AI Studio/Provider/Perplexity/Delta.cs index a4f1da610..82d34b417 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/Delta.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/Delta.cs @@ -1,7 +1,17 @@ +using System.Text.Json; + +using AIStudio.Provider.OpenAI; + namespace AIStudio.Provider.Perplexity; /// /// The delta text of a choice. /// /// The content of the delta text. -public readonly record struct Delta(string Content); \ No newline at end of file +/// OpenAI-compatible reasoning content. +/// OpenRouter-compatible reasoning content. +/// Structured reasoning details. +public readonly record struct Delta(string Content, string? ReasoningContent, string? Reasoning, IList? ReasoningDetails) +{ + public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails); +} diff --git a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs index 5ef740830..318b9df3c 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs @@ -15,11 +15,11 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint public bool ContainsContent() => this != default && this.Choices.Count > 0; /// - public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.GetSources()); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.Choices[0].Delta.Thinking, this.GetSources()); /// public bool ContainsSources() => this != default && this.SearchResults.Count > 0; /// public IList GetSources() => this.SearchResults.Cast().ToList(); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index 76c764545..a3155ae15 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -273,7 +273,6 @@ private async Task RunChatGenerationAsync(AIJobState state) catch (ProviderRequestException e) { logger.LogError(e, "The provider request failed for chat generation job '{JobId}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", state.Snapshot.JobId, e.StatusCode, e.ReasonPhrase, e.ResponseBody); - RemoveEmptyAIResponse(state); await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, e.UserMessage); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); } @@ -302,7 +301,7 @@ private async Task CompleteChatGenerationAsync(AIJobState state, AIJobStatus sta var aiText = request.AIText; aiText.InitialRemoteWait = false; aiText.IsStreaming = false; - aiText.Text = aiText.Text.RemoveThinkTags().Trim(); + aiText.FinalizeStreamContent(); RemoveEmptyAIResponse(state); @@ -367,7 +366,7 @@ private static void RemoveEmptyAIResponse(AIJobState state) return; var aiText = request.AIText; - if (!string.IsNullOrWhiteSpace(aiText.Text)) + if (!string.IsNullOrWhiteSpace(aiText.Text) || !string.IsNullOrWhiteSpace(aiText.Thinking)) return; var aiBlock = request.ChatThread.Blocks @@ -399,8 +398,7 @@ private static bool TryApplyStreamChunk(AIJobState state, ContentStreamChunk con var aiText = state.ChatGenerationRequest.AIText; aiText.InitialRemoteWait = false; aiText.IsStreaming = true; - aiText.Text += contentStreamChunk; - aiText.Sources.MergeSources(contentStreamChunk.Sources); + aiText.ApplyStreamChunk(contentStreamChunk); if (state.Snapshot.Status is not AIJobStatus.RUNNING) { @@ -519,4 +517,4 @@ private async Task CheckSelectedModelAvailability(IProvider provider, Mode logger.LogWarning("Skipping AI request because model '{ModelId}' is not available from '{ProviderInstanceName}' (provider={ProviderType}).", chatModel.Id, provider.InstanceName, provider.Provider); return false; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs index e9c897ebe..61add4bef 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs @@ -28,6 +28,7 @@ public async IAsyncEnumerable RunAsync( var toolCallCount = 0; var toolResultCharacterCount = 0L; var toolSources = new List(); + var hasThinkingOutput = false; while (true) { @@ -47,6 +48,13 @@ public async IAsyncEnumerable RunAsync( toolSources.MergeSources(round.Sources); + if (!string.IsNullOrWhiteSpace(round.ThinkingOutput)) + { + var separator = hasThinkingOutput ? $"{Environment.NewLine}{Environment.NewLine}" : string.Empty; + yield return new ContentStreamChunk(string.Empty, $"{separator}{round.ThinkingOutput}", []); + hasThinkingOutput = true; + } + // // A call without an ID cannot be answered: the provider correlates the result by that // ID, and inventing one would have the next request rejected. Nothing can be salvaged @@ -168,4 +176,4 @@ public async IAsyncEnumerable RunAsync( private static string GetDisplayName(ToolCallingLoopContext context, string toolName) => context.RunnableTools .FirstOrDefault(tool => tool.Definition.Function.Name.Equals(toolName, StringComparison.Ordinal)) .Implementation?.GetDisplayName() ?? toolName; -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingRound.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingRound.cs index 7ec857343..7a1c961ba 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingRound.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingRound.cs @@ -5,6 +5,7 @@ namespace AIStudio.Tools.ToolCallingSystem.Harness; /// longer depends on the provider API it came from. /// /// The text the model produced, empty when it only requested tool calls. +/// The human-readable thinking the provider exposed. /// The tool calls the model requested, empty when it answered instead. /// Sources the provider itself attached, such as those of a provider-native web search. -public sealed record ToolCallingRound(string TextOutput, IReadOnlyList Calls, IReadOnlyList Sources); \ No newline at end of file +public sealed record ToolCallingRound(string TextOutput, string ThinkingOutput, IReadOnlyList Calls, IReadOnlyList Sources); diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md index 192554b0a..b8cdfbe45 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -8,6 +8,7 @@ - Added tools to the Assistant Builder. For a direct-chat launcher you pick them yourself, alongside the workspace, provider, and data sources. For an assistant, the AI chooses from the tools installed here and says so in the draft, so you see the decision before the assistant is written. - Added organization-wide management for tools. Among other options, IT departments can switch tools off entirely, disable individual ones, or define the provider trust a tool requires. You do not have to write any of it by hand: set a tool up in the app, then export its configuration as ready-made Lua code for your plugin, with encrypted API keys if you want them. - Added tool calling to the abilities you can state yourself in the expert provider settings. When you use a model AI Studio does not recognize as tool-capable, you can now declare that it is, the same way you already could for image input or reasoning. +- Added a Thinking section to AI answers when a model shares details about how it reached its result. The section stays collapsed until you choose to open it, updates while the model is working, and remains available when you reopen a saved chat. - Added local RAG as a beta feature, so the AI can answer from your own documents. You point AI Studio at a folder or at a single file, and it prepares those documents in the background so their contents can be found again later. Ask a question with such a data source selected, and AI Studio looks for the passages that fit your question and hands only those to the model, along with where each one came from. We will keep developing it together with the people who use it: to try it, open the app settings, allow preview features down to beta, and then enable the RAG feature. Many thanks to Paul Koudelka (`PaulKoudelka`) for around ten months of work on the concept and the implementation. - Added the setup for local data sources. You pick an embedding provider, and AI Studio asks for your confirmation before any document goes to a cloud service. It keeps up with your files as they change, shows the progress on a page of its own, and checks every document for hidden instructions before indexing it. Documents without readable text, such as scanned pages, are remembered as such, so AI Studio does not work through them again after every start — it comes back to them once they change. - Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty. From b28f2bcf6c3e88fcd15c5ab8aaeb2411f2945c34 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:02:25 +0200 Subject: [PATCH 2/3] Fixing some things left over --- app/MindWork AI Studio/Provider/Model.cs | 21 +++++++- .../OpenAI/ResponsesToolCallingAdapter.cs | 35 +++++++++++- .../Provider/OpenAI/ThinkingContent.cs | 4 +- .../Provider/OpenRouter/OpenRouterModel.cs | 22 +++++++- .../Provider/OpenRouter/ProviderOpenRouter.cs | 4 +- .../Settings/ProviderExtensions.Gateway.cs | 26 ++++++++- .../Settings/ProviderExtensions.Reasoning.cs | 53 +++++++++++++++---- 7 files changed, 147 insertions(+), 18 deletions(-) diff --git a/app/MindWork AI Studio/Provider/Model.cs b/app/MindWork AI Studio/Provider/Model.cs index 97ca3bbfc..5961a2a69 100644 --- a/app/MindWork AI Studio/Provider/Model.cs +++ b/app/MindWork AI Studio/Provider/Model.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + using AIStudio.Tools.PluginSystem; namespace AIStudio.Provider; @@ -20,6 +22,12 @@ public readonly record struct Model(string Id, string? DisplayName) /// public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null); + /// + /// The provider-reported default reasoning behavior, when the model catalog supplies it. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public ModelReasoningBehavior ReasoningBehavior { get; init; } + /// /// Checks if this model is the system-configured placeholder. /// @@ -71,4 +79,15 @@ public override string ToString() public override int GetHashCode() => this.Id?.GetHashCode(StringComparison.Ordinal) ?? 0; #endregion -} \ No newline at end of file +} + +/// +/// Describes the default reasoning behavior reported by a provider's model catalog. +/// +public enum ModelReasoningBehavior +{ + UNKNOWN, + OPTIONAL, + DEFAULT_ON, + ALWAYS_ON, +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs index 5d30a6234..f039a14b2 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs @@ -15,6 +15,8 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools, Func> executeRequestAsync) : IToolCallingProviderAdapter { + private const string ENCRYPTED_REASONING_INCLUDE = "reasoning.encrypted_content"; + private readonly List internalItems = []; private ResponsesResponse? lastResponse; @@ -48,7 +50,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b Stream = false, Store = false, Tools = includeTools ? this.effectiveProviderTools : [], - AdditionalApiParameters = apiParameters, + AdditionalApiParameters = IncludeEncryptedReasoning(apiParameters), }, token); if (response is null) @@ -92,6 +94,37 @@ public void RecordToolResult(string callId, string content, bool isError = false Output = content, }); + /// + /// Request encrypted reasoning content without replacing any additional output data selected by the user. + /// + /// + /// Tool rounds use stateless Responses requests. OpenAI requires encrypted reasoning items in that mode + /// so that the complete output can be passed back with the tool result on the next round. + /// + private static IDictionary IncludeEncryptedReasoning(IDictionary apiParameters) + { + var result = new Dictionary(apiParameters); + var includeKey = result.Keys.FirstOrDefault(key => key.Equals("include", StringComparison.OrdinalIgnoreCase)); + if (includeKey is null) + { + result["include"] = new List { ENCRYPTED_REASONING_INCLUDE }; + return result; + } + + var includedOutput = result[includeKey] switch + { + IEnumerable values => values.ToList(), + string value => new List { value }, + _ => [], + }; + + if (!includedOutput.Any(value => string.Equals(value as string, ENCRYPTED_REASONING_INCLUDE, StringComparison.Ordinal))) + includedOutput.Add(ENCRYPTED_REASONING_INCLUDE); + + result[includeKey] = includedOutput; + return result; + } + private static IList BuildEffectiveProviderTools(IList providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools) { var localFunctionNames = runnableTools diff --git a/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs b/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs index 114258cd7..ab40f9966 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs @@ -9,10 +9,10 @@ internal static class ThinkingContent { public static string Get(string? reasoningContent, string? reasoning, IEnumerable? reasoningDetails) { - if (!string.IsNullOrWhiteSpace(reasoningContent)) + if (!string.IsNullOrEmpty(reasoningContent)) return reasoningContent; - if (!string.IsNullOrWhiteSpace(reasoning)) + if (!string.IsNullOrEmpty(reasoning)) return reasoning; if (reasoningDetails is null) diff --git a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs index 7cd47a597..c05d2475f 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs @@ -5,4 +5,24 @@ namespace AIStudio.Provider.OpenRouter; /// /// The model's ID. /// The model's human-readable display name. -public readonly record struct OpenRouterModel(string Id, string? Name); +/// The model's provider-reported reasoning behavior. +public readonly record struct OpenRouterModel(string Id, string? Name, OpenRouterReasoning? Reasoning) +{ + public Model ToModel() => new(this.Id, this.Name) + { + ReasoningBehavior = this.Reasoning switch + { + { Mandatory: true } => ModelReasoningBehavior.ALWAYS_ON, + { DefaultEnabled: true } => ModelReasoningBehavior.DEFAULT_ON, + not null => ModelReasoningBehavior.OPTIONAL, + _ => ModelReasoningBehavior.UNKNOWN, + }, + }; +} + +/// +/// The reasoning defaults returned for a model by OpenRouter's model catalog. +/// +/// Whether reasoning is enabled when the request does not configure it. +/// Whether reasoning cannot be disabled for the model. +public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory); diff --git a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs index 842b9fc61..995d34632 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs @@ -116,7 +116,7 @@ private Task LoadModels(SecretStoreType storeType, string? apiK storeType, "models", modelResponse => modelResponse.Data - .Select(n => new Model(n.Id, n.Name)) + .Select(n => n.ToModel()) .Where(model => model.IsChatModel()), apiKeyProvisional, requestConfigurator: (request, secretKey) => @@ -133,7 +133,7 @@ private Task LoadEmbeddingModels(string? apiKeyProvisional, Can return this.LoadModelsResponse( SecretStoreType.EMBEDDING_PROVIDER, "embeddings/models", - modelResponse => modelResponse.Data.Select(n => new Model(n.Id, n.Name)), + modelResponse => modelResponse.Data.Select(n => n.ToModel()), apiKeyProvisional, requestConfigurator: (request, secretKey) => { diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs index c11233818..ac7898ea1 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs @@ -57,7 +57,29 @@ private static List GetModelCapabilitiesGateway(Model model) _ => GetModelCapabilitiesOpenSource(bareModel), }; - return NormalizeForGateway(capabilities); + return ApplyGatewayReasoningBehavior(NormalizeForGateway(capabilities), model.ReasoningBehavior); + } + + /// + /// Applies provider-reported reasoning behavior in preference to model-name heuristics. + /// + private static List ApplyGatewayReasoningBehavior(List capabilities, ModelReasoningBehavior reasoningBehavior) + { + if (reasoningBehavior is ModelReasoningBehavior.UNKNOWN) + return capabilities; + + capabilities.Remove(Capability.OPTIONAL_REASONING); + capabilities.Remove(Capability.REASONING_BY_DEFAULT); + capabilities.Remove(Capability.ALWAYS_REASONING); + capabilities.Add(reasoningBehavior switch + { + ModelReasoningBehavior.OPTIONAL => Capability.OPTIONAL_REASONING, + ModelReasoningBehavior.DEFAULT_ON => Capability.REASONING_BY_DEFAULT, + ModelReasoningBehavior.ALWAYS_ON => Capability.ALWAYS_REASONING, + _ => throw new ArgumentOutOfRangeException(nameof(reasoningBehavior), reasoningBehavior, null), + }); + + return capabilities; } /// @@ -78,4 +100,4 @@ private static List NormalizeForGateway(List capabilitie return capabilities; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs index 25d687b4c..c239f2577 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs @@ -146,21 +146,55 @@ LLMProviders.HELMHOLTZ or /// private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary parameters) { - var reasoningState = ReasoningConfigurationState.NOT_CONFIGURED; + var states = new List(); if (TryGetParameter(parameters, "reasoning", out var reasoning)) { - reasoningState = reasoning switch + if (reasoning is IDictionary reasoningObject) { - IDictionary reasoningObject when TryGetParameter(reasoningObject, "effort", out var effort) => GetLevelState(effort), - IDictionary reasoningObject when TryGetParameter(reasoningObject, "summary", out var summary) => GetLevelState(summary), - IDictionary => ReasoningConfigurationState.NOT_CONFIGURED, - _ => GetLevelState(reasoning), - }; + if (TryGetParameter(reasoningObject, "effort", out var effort)) + states.Add(GetLevelState(effort)); + + if (TryGetParameter(reasoningObject, "enabled", out var enabled)) + states.Add(GetLevelState(enabled)); + + if (TryGetParameter(reasoningObject, "max_tokens", out var maxTokens)) + states.Add(GetBudgetState(maxTokens)); + + if (TryGetParameter(reasoningObject, "summary", out var summary)) + states.Add(GetReasoningSummaryState(summary)); + + if (TryGetParameter(reasoningObject, "generate_summary", out var generateSummary)) + states.Add(GetReasoningSummaryState(generateSummary)); + } + else + states.Add(GetLevelState(reasoning)); } - return MergeReasoningStates(reasoningState, GetReasoningEffortState(parameters)); + if (TryGetParameter(parameters, "include_reasoning", out var includeReasoning) && + GetLevelState(includeReasoning) is ReasoningConfigurationState.EXPLICITLY_ENABLED) + states.Add(ReasoningConfigurationState.EXPLICITLY_ENABLED); + + states.Add(GetReasoningEffortState(parameters)); + return MergeReasoningStates(states); } + /// + /// Detect summary settings that imply reasoning is enabled. + /// + /// + /// Turning a summary off controls visibility only and does not disable the model's reasoning. + /// + private static ReasoningConfigurationState GetReasoningSummaryState(object? value) => value switch + { + string text when text.Equals("auto", StringComparison.OrdinalIgnoreCase) || + text.Equals("concise", StringComparison.OrdinalIgnoreCase) || + text.Equals("detailed", StringComparison.OrdinalIgnoreCase) + => ReasoningConfigurationState.EXPLICITLY_ENABLED, + + true => ReasoningConfigurationState.EXPLICITLY_ENABLED, + _ => ReasoningConfigurationState.NOT_CONFIGURED, + }; + /// /// Detect a top-level reasoning_effort parameter. /// @@ -444,6 +478,7 @@ private static bool IsEnabledText(string text) text.Equals("minimal", StringComparison.OrdinalIgnoreCase) || text.Equals("medium", StringComparison.OrdinalIgnoreCase) || text.Equals("high", StringComparison.OrdinalIgnoreCase) || + text.Equals("xhigh", StringComparison.OrdinalIgnoreCase) || text.Equals("max", StringComparison.OrdinalIgnoreCase); } @@ -516,4 +551,4 @@ private static bool TryGetParameter(IDictionary parameters, stri value = parameters[foundKey]; return true; } -} \ No newline at end of file +} From fc30b33daa8267ac6e1c303bc3373518eeace027 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:32:38 +0200 Subject: [PATCH 3/3] Polish per-answer thinking disclosure --- .../Chat/ContentBlockComponent.razor | 3 +- app/MindWork AI Studio/Chat/ContentText.cs | 10 ++++-- .../Provider/Anthropic/AnthropicResponse.cs | 13 ++++++-- .../Provider/Anthropic/ResponseStreamLine.cs | 4 +-- .../Provider/BaseProvider.cs | 9 ++++++ .../Provider/ContentStreamChunk.cs | 8 ++--- .../Provider/Fireworks/ResponseStreamLine.cs | 2 +- app/MindWork AI Studio/Provider/Model.cs | 18 ++++------- .../Provider/ModelReasoningBehavior.cs | 32 +++++++++++++++++++ .../OpenAI/ChatCompletionDeltaStreamLine.cs | 2 +- .../Provider/OpenAI/ProviderOpenAI.cs | 6 ++++ .../OpenAI/ResponsesDeltaStreamLine.cs | 25 ++++++++++++--- .../Provider/OpenAI/ResponsesResponse.cs | 14 +++++++- .../OpenAI/ResponsesToolCallingAdapter.cs | 15 ++++++--- .../Provider/OpenRouter/OpenRouterModel.cs | 10 ++---- .../OpenRouter/OpenRouterReasoning.cs | 12 +++++++ .../Provider/Perplexity/ResponseStreamLine.cs | 2 +- .../Settings/ProviderExtensions.Reasoning.cs | 10 ++---- .../Tools/AIJobs/AIJobService.cs | 4 ++- .../Harness/ToolCallingLoop.cs | 2 +- 20 files changed, 147 insertions(+), 54 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs create mode 100644 app/MindWork AI Studio/Provider/OpenRouter/OpenRouterReasoning.cs diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index 95b450b73..2649e17e0 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -226,8 +226,9 @@ } else if (this.Content.IsStreaming) { + @* The think tags never reach the text: ContentText splits them off while streaming. *@ - @textContent.Text.RemoveThinkTags() + @textContent.Text } else diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index c4f37315a..814f217ae 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -127,13 +127,19 @@ await Task.Run(async () => if (token.IsCancellationRequested) break; - // Stop the waiting animation: - this.InitialRemoteWait = false; this.IsStreaming = true; // Add the response to the content: this.ApplyStreamChunk(contentStreamChunk); + // + // Stop the waiting animation once the answer itself starts. A model which + // is still reasoning has not written anything to read yet, and an empty + // bubble would look like a finished, empty answer. The thinking section + // is available next to the animation the whole time. + // + this.InitialRemoteWait = this.Text.Length is 0; + // Notify the UI that the content has changed, // depending on the energy saving mode: var now = DateTimeOffset.Now; diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs index c320c050c..9c65b65a8 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs @@ -39,9 +39,16 @@ public string GetTextOutput() => string.Concat(this.Content /// /// The human-readable thinking the model returned, with redacted blocks omitted. /// - public string GetThinkingOutput() => string.Concat(this.Content - .Where(x => ReadString(x, "type").Equals("thinking", StringComparison.Ordinal)) - .Select(x => ReadString(x, "thinking"))); + /// + /// Each thinking block reads as its own paragraph, so they are joined as paragraphs + /// rather than run into one another. + /// + public string GetThinkingOutput() => string.Join( + $"{Environment.NewLine}{Environment.NewLine}", + this.Content + .Where(x => ReadString(x, "type").Equals("thinking", StringComparison.Ordinal)) + .Select(x => ReadString(x, "thinking")) + .Where(x => !string.IsNullOrWhiteSpace(x))); private static string ReadString(JsonElement item, string propertyName) { diff --git a/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs index ef47ccbe5..5f831353e 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs @@ -16,8 +16,8 @@ public readonly record struct ResponseStreamLine(string Type, int Index, Delta D /// public ContentStreamChunk GetContent() => this.Delta.Type switch { - "thinking_delta" => new(string.Empty, this.Delta.Thinking, []), - _ => new(this.Delta.Text, string.Empty, []), + "thinking_delta" => new(string.Empty, [], this.Delta.Thinking), + _ => new(this.Delta.Text, []), }; #region Implementation of IAnnotationStreamLine diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 445228a44..07f4507b5 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1054,6 +1054,15 @@ protected async IAsyncEnumerable StreamResponsesInternal /// The text content of the chunk. -/// The provider-exposed thinking content of the chunk. /// The list of sources associated with the chunk. -public sealed record ContentStreamChunk(string Content, string Thinking, IList Sources) +/// The provider-exposed thinking content of the chunk. +public sealed record ContentStreamChunk(string Content, IList Sources, string Thinking = "") { - public ContentStreamChunk(string content, IList sources) : this(content, string.Empty, sources) - { - } - /// /// Implicit conversion to string. /// diff --git a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs index a0869f3ee..625308c63 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs @@ -14,7 +14,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint public bool ContainsContent() => this != default && this.Choices.Count > 0; /// - public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.Choices[0].Delta.Thinking, []); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, [], this.Choices[0].Delta.Thinking); #region Implementation of IAnnotationStreamLine diff --git a/app/MindWork AI Studio/Provider/Model.cs b/app/MindWork AI Studio/Provider/Model.cs index 5961a2a69..548ee5745 100644 --- a/app/MindWork AI Studio/Provider/Model.cs +++ b/app/MindWork AI Studio/Provider/Model.cs @@ -25,6 +25,13 @@ public readonly record struct Model(string Id, string? DisplayName) /// /// The provider-reported default reasoning behavior, when the model catalog supplies it. /// + /// + /// This travels with the selected model into the user's settings, so it is a copy of what the + /// catalog said when the user picked the model, not a live value. It is refreshed when the user + /// picks a model again. That is deliberate: the capability lookup works from the stored model + /// and must not depend on the provider being reachable. + /// falls back to the model-name heuristics, which is also what every older settings file yields. + /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public ModelReasoningBehavior ReasoningBehavior { get; init; } @@ -80,14 +87,3 @@ public override string ToString() #endregion } - -/// -/// Describes the default reasoning behavior reported by a provider's model catalog. -/// -public enum ModelReasoningBehavior -{ - UNKNOWN, - OPTIONAL, - DEFAULT_ON, - ALWAYS_ON, -} diff --git a/app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs b/app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs new file mode 100644 index 000000000..535edcf56 --- /dev/null +++ b/app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs @@ -0,0 +1,32 @@ +namespace AIStudio.Provider; + +/// +/// Describes the default reasoning behavior reported by a provider's model catalog. +/// +/// +/// A catalog which reports this is more reliable than our model-name heuristics, so +/// takes precedence over them. Not every provider +/// reports it, which is what stands for. +/// +public enum ModelReasoningBehavior +{ + /// + /// The catalog said nothing about reasoning. The model-name heuristics decide. + /// + UNKNOWN, + + /// + /// The model can reason, but does not unless the request asks for it. + /// + OPTIONAL, + + /// + /// The model reasons unless the request switches it off. + /// + DEFAULT_ON, + + /// + /// The model always reasons and it cannot be switched off. + /// + ALWAYS_ON, +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs index 7b686ac3a..32489bc78 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs @@ -19,7 +19,7 @@ public ChatCompletionDeltaStreamLine() : this(string.Empty, string.Empty, 0, str public bool ContainsContent() => this.Choices.Count > 0; /// - public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.Choices[0].Delta.Thinking, []); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, [], this.Choices[0].Delta.Thinking); #region Implementation of IAnnotationStreamLine diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index 07ece6064..7082ca6a2 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -96,6 +96,11 @@ public override async IAsyncEnumerable StreamChatCompletion( // Check if we are using the Responses API or the Chat Completion API: var usingResponsesAPI = modelCapabilities.Contains(Capability.RESPONSES_API); + + // Whether the model reasons at all, no matter whether it may be switched off: + var isReasoningModel = modelCapabilities.Contains(Capability.ALWAYS_REASONING) || + modelCapabilities.Contains(Capability.REASONING_BY_DEFAULT) || + modelCapabilities.Contains(Capability.OPTIONAL_REASONING); // Prepare the request path based on the API we are using: var requestPath = usingResponsesAPI ? "responses" : "chat/completions"; @@ -225,6 +230,7 @@ public override async IAsyncEnumerable StreamChatCompletion( { var adapter = new ResponsesToolCallingAdapter( chatModel, + isReasoningModel, baseInput, additionalApiParameters, providerTools, diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs index 2a7f47774..b38778a5a 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs @@ -5,20 +5,37 @@ namespace AIStudio.Provider.OpenAI; /// /// The type of the response. /// The delta content of the response. +/// The reasoning summary part this line belongs to. public record ResponsesDeltaStreamLine( string Type, - string? Delta) : IResponseStreamLine + string? Delta, + int SummaryIndex) : IResponseStreamLine { + private const string SUMMARY_PART_ADDED = "response.reasoning_summary_part.added"; + private const string SUMMARY_TEXT_DELTA = "response.reasoning_summary_text.delta"; + private const string REASONING_TEXT_DELTA = "response.reasoning_text.delta"; + + /// + /// Whether this line starts a reasoning summary part which follows an earlier one. + /// + /// + /// A reasoning summary arrives as several parts, and each one reads as its own paragraph. + /// The stream carries no separator between them, so the boundary has to come from the + /// event which announces the next part. + /// + private bool IsFollowUpSummaryPart => this.Type is SUMMARY_PART_ADDED && this.SummaryIndex > 0; + #region Implementation of IResponseStreamLine /// - public bool ContainsContent() => this.Delta is not null; + public bool ContainsContent() => this.Delta is not null || this.IsFollowUpSummaryPart; /// public ContentStreamChunk GetContent() => this.Type switch { - "response.reasoning_summary_text.delta" or "response.reasoning_text.delta" => new(string.Empty, this.Delta ?? string.Empty, this.GetSources()), - _ => new(this.Delta ?? string.Empty, string.Empty, this.GetSources()), + SUMMARY_PART_ADDED => new(string.Empty, this.GetSources(), this.IsFollowUpSummaryPart ? $"{Environment.NewLine}{Environment.NewLine}" : string.Empty), + SUMMARY_TEXT_DELTA or REASONING_TEXT_DELTA => new(string.Empty, this.GetSources(), this.Delta ?? string.Empty), + _ => new(this.Delta ?? string.Empty, this.GetSources()), }; // diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs index f0266ea2c..b2be4fb33 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs @@ -42,11 +42,23 @@ public string GetTextOutput() })); } - public string GetThinkingOutput() => string.Concat(this.Output + /// + /// The human-readable thinking the model returned. + /// + /// + /// A reasoning item carries its summary and its raw text as arrays of parts, and each part + /// reads as its own paragraph. The API states no separator between them, so we join them + /// as paragraphs instead of running them into one another. + /// + public string GetThinkingOutput() => JoinParagraphs(this.Output .Where(x => ReadString(x, "type").Equals("reasoning", StringComparison.Ordinal)) .SelectMany(x => ReadArrayItems(x, "summary").Concat(ReadArrayItems(x, "content"))) .Select(x => ReadString(x, "text"))); + private static string JoinParagraphs(IEnumerable parts) => string.Join( + $"{Environment.NewLine}{Environment.NewLine}", + parts.Where(part => !string.IsNullOrWhiteSpace(part))); + public IReadOnlyList GetSources() => this.Output .Where(x => ReadString(x, "type").Equals("message", StringComparison.Ordinal)) .SelectMany(ReadContentItems) diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs index f039a14b2..6c0ca70aa 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs @@ -11,7 +11,7 @@ namespace AIStudio.Provider.OpenAI; /// correlated by call ID. Unlike Chat Completions, the whole output of a round has to be sent /// back for the next one, reasoning items included, or the API refuses to continue. /// -public sealed class ResponsesToolCallingAdapter(Model chatModel, IList baseInput, IDictionary apiParameters, IList providerTools, +public sealed class ResponsesToolCallingAdapter(Model chatModel, bool isReasoningModel, IList baseInput, IDictionary apiParameters, IList providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools, Func> executeRequestAsync) : IToolCallingProviderAdapter { @@ -50,7 +50,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b Stream = false, Store = false, Tools = includeTools ? this.effectiveProviderTools : [], - AdditionalApiParameters = IncludeEncryptedReasoning(apiParameters), + AdditionalApiParameters = isReasoningModel ? IncludeEncryptedReasoning(apiParameters) : apiParameters, }, token); if (response is null) @@ -99,7 +99,8 @@ public void RecordToolResult(string callId, string content, bool isError = false /// /// /// Tool rounds use stateless Responses requests. OpenAI requires encrypted reasoning items in that mode - /// so that the complete output can be passed back with the tool result on the next round. + /// so that the complete output can be passed back with the tool result on the next round.

+ /// Only a reasoning model gets this, because the include is meaningless for every other one. ///
private static IDictionary IncludeEncryptedReasoning(IDictionary apiParameters) { @@ -111,11 +112,15 @@ private static IDictionary IncludeEncryptedReasoning(IDictionary return result; } + // + // Whatever the user put there stays. A value we cannot read as a list is kept as the + // single entry it appears to be: the API rejecting the user's own value is the honest + // outcome, while dropping it here would hide the mistake behind a request that works. + // var includedOutput = result[includeKey] switch { IEnumerable values => values.ToList(), - string value => new List { value }, - _ => [], + var value => [value], }; if (!includedOutput.Any(value => string.Equals(value as string, ENCRYPTED_REASONING_INCLUDE, StringComparison.Ordinal))) diff --git a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs index c05d2475f..012975fa1 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs @@ -8,6 +8,9 @@ namespace AIStudio.Provider.OpenRouter; /// The model's provider-reported reasoning behavior. public readonly record struct OpenRouterModel(string Id, string? Name, OpenRouterReasoning? Reasoning) { + /// + /// Converts the catalog entry into the model the rest of the app works with. + /// public Model ToModel() => new(this.Id, this.Name) { ReasoningBehavior = this.Reasoning switch @@ -19,10 +22,3 @@ public readonly record struct OpenRouterModel(string Id, string? Name, OpenRoute }, }; } - -/// -/// The reasoning defaults returned for a model by OpenRouter's model catalog. -/// -/// Whether reasoning is enabled when the request does not configure it. -/// Whether reasoning cannot be disabled for the model. -public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory); diff --git a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterReasoning.cs b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterReasoning.cs new file mode 100644 index 000000000..dcba875a4 --- /dev/null +++ b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterReasoning.cs @@ -0,0 +1,12 @@ +namespace AIStudio.Provider.OpenRouter; + +/// +/// The reasoning defaults returned for a model by OpenRouter's model catalog. +/// +/// +/// The catalog reports more than this, such as the supported efforts and the default one. +/// We read only what decides the model's reasoning capability today. +/// +/// Whether reasoning is enabled when the request does not configure it. +/// Whether reasoning cannot be disabled for the model. +public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory); diff --git a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs index 318b9df3c..68b4d2ec2 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs @@ -15,7 +15,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint public bool ContainsContent() => this != default && this.Choices.Count > 0; /// - public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.Choices[0].Delta.Thinking, this.GetSources()); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.GetSources(), this.Choices[0].Delta.Thinking); /// public bool ContainsSources() => this != default && this.SearchResults.Count > 0; diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs index c239f2577..7ebfb89ba 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs @@ -73,9 +73,7 @@ private static ReasoningConfigurationState GetReasoningConfigurationState(Provid return provider.UsedLLMProvider switch { - LLMProviders.OPEN_AI => MergeReasoningStates( - GetOpenAICompatibleReasoningState(parameters), - GetReasoningEffortState(parameters)), + LLMProviders.OPEN_AI => GetOpenAICompatibleReasoningState(parameters), LLMProviders.ANTHROPIC => GetAnthropicReasoningState(parameters), @@ -101,7 +99,6 @@ LLMProviders.HUGGINGFACE or LLMProviders.HELMHOLTZ or LLMProviders.GWDG => MergeReasoningStates( GetOpenAICompatibleReasoningState(parameters), - GetReasoningEffortState(parameters), GetQwenReasoningState(parameters), GetGoogleReasoningState(parameters)), @@ -119,14 +116,12 @@ LLMProviders.HELMHOLTZ or Host.VLLM => MergeReasoningStates( GetOpenAICompatibleReasoningState(parameters), - GetReasoningEffortState(parameters), GetVllmReasoningState(parameters), GetQwenReasoningState(parameters), GetGoogleReasoningState(parameters)), _ => MergeReasoningStates( GetOpenAICompatibleReasoningState(parameters), - GetReasoningEffortState(parameters), GetQwenReasoningState(parameters), GetGoogleReasoningState(parameters)), }, @@ -142,7 +137,8 @@ LLMProviders.HELMHOLTZ or /// The detected reasoning configuration state. /// /// OpenAI-compatible providers commonly use a nested reasoning object and/or - /// a top-level reasoning_effort parameter. + /// a top-level reasoning_effort parameter. Both are covered here, so a caller + /// does not merge on top of this one again. /// private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary parameters) { diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index a3155ae15..5fee6065b 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -396,10 +396,12 @@ private static bool TryApplyStreamChunk(AIJobState state, ContentStreamChunk con return false; var aiText = state.ChatGenerationRequest.AIText; - aiText.InitialRemoteWait = false; aiText.IsStreaming = true; aiText.ApplyStreamChunk(contentStreamChunk); + // The waiting animation stays until the answer itself starts, cf. ContentText: + aiText.InitialRemoteWait = aiText.Text.Length is 0; + if (state.Snapshot.Status is not AIJobStatus.RUNNING) { state.Snapshot = state.Snapshot with diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs index 61add4bef..619e806ba 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs @@ -51,7 +51,7 @@ public async IAsyncEnumerable RunAsync( if (!string.IsNullOrWhiteSpace(round.ThinkingOutput)) { var separator = hasThinkingOutput ? $"{Environment.NewLine}{Environment.NewLine}" : string.Empty; - yield return new ContentStreamChunk(string.Empty, $"{separator}{round.ThinkingOutput}", []); + yield return new ContentStreamChunk(string.Empty, [], $"{separator}{round.ThinkingOutput}"); hasThinkingOutput = true; }