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] 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 95b450b7..2649e17e 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 c4f37315..814f217a 100644
--- a/app/MindWork AI Studio/Chat/ContentText.cs
+++ b/app/MindWork AI Studio/Chat/ContentText.cs
@@ -127,13 +127,19 @@ public sealed class ContentText : IContent
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 c320c050..9c65b65a 100644
--- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs
+++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs
@@ -39,9 +39,16 @@ public sealed record AnthropicResponse
///
/// 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 ef47ccbe..5f831353 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 445228a4..07f4507b 100644
--- a/app/MindWork AI Studio/Provider/BaseProvider.cs
+++ b/app/MindWork AI Studio/Provider/BaseProvider.cs
@@ -1054,6 +1054,15 @@ public abstract class BaseProvider : IProvider, ISecretId
""", StringComparison.InvariantCulture) ||
jsonData.StartsWith("""
{"type":"response.reasoning_text.delta"
+ """, StringComparison.InvariantCulture) ||
+
+ //
+ // Not a delta of its own: it announces the next part of a reasoning summary.
+ // The stream carries no separator between those parts, so the stream line
+ // turns this event into the paragraph break between them.
+ //
+ jsonData.StartsWith("""
+ {"type":"response.reasoning_summary_part.added"
""", StringComparison.InvariantCulture))
{
TDelta? providerResponse;
diff --git a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs
index a6c7347e..60cbebb7 100644
--- a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs
+++ b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs
@@ -4,14 +4,10 @@ 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, 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 a0869f3e..625308c6 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 5961a2a6..548ee574 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 readonly record struct Model(string Id, string? DisplayName)
#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 00000000..535edcf5
--- /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 7b686ac3..32489bc7 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs
@@ -19,7 +19,7 @@ public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat
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 07ece606..7082ca6a 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs
@@ -96,6 +96,11 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
// 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 sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
{
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 2a7f4777..b38778a5 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 f0266ea2..b2be4fb3 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs
@@ -42,11 +42,23 @@ public sealed record ResponsesResponse
}));
}
- 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 f039a14b..6c0ca70a 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