Polish per-answer thinking disclosure

This commit is contained in:
Peer Hogeterp 2026-09-10 17:32:38 +02:00
parent b28f2bcf6c
commit fc30b33daa
20 changed files with 147 additions and 54 deletions

View File

@ -226,8 +226,9 @@
}
else if (this.Content.IsStreaming)
{
@* The think tags never reach the text: ContentText splits them off while streaming. *@
<MudText Typo="Typo.body1" Style="white-space: pre-wrap;">
@textContent.Text.RemoveThinkTags()
@textContent.Text
</MudText>
}
else

View File

@ -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;

View File

@ -39,9 +39,16 @@ public sealed record AnthropicResponse
/// <summary>
/// The human-readable thinking the model returned, with redacted blocks omitted.
/// </summary>
public string GetThinkingOutput() => string.Concat(this.Content
.Where(x => ReadString(x, "type").Equals("thinking", StringComparison.Ordinal))
.Select(x => ReadString(x, "thinking")));
/// <remarks>
/// Each thinking block reads as its own paragraph, so they are joined as paragraphs
/// rather than run into one another.
/// </remarks>
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)
{

View File

@ -16,8 +16,8 @@ public readonly record struct ResponseStreamLine(string Type, int Index, Delta D
/// <inheritdoc />
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

View File

@ -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;

View File

@ -4,14 +4,10 @@ namespace AIStudio.Provider;
/// A chunk of content from a content stream, along with its associated sources.
/// </summary>
/// <param name="Content">The text content of the chunk.</param>
/// <param name="Thinking">The provider-exposed thinking content of the chunk.</param>
/// <param name="Sources">The list of sources associated with the chunk.</param>
public sealed record ContentStreamChunk(string Content, string Thinking, IList<ISource> Sources)
/// <param name="Thinking">The provider-exposed thinking content of the chunk.</param>
public sealed record ContentStreamChunk(string Content, IList<ISource> Sources, string Thinking = "")
{
public ContentStreamChunk(string content, IList<ISource> sources) : this(content, string.Empty, sources)
{
}
/// <summary>
/// Implicit conversion to string.
/// </summary>

View File

@ -14,7 +14,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint
public bool ContainsContent() => this != default && this.Choices.Count > 0;
/// <inheritdoc />
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

View File

@ -25,6 +25,13 @@ public readonly record struct Model(string Id, string? DisplayName)
/// <summary>
/// The provider-reported default reasoning behavior, when the model catalog supplies it.
/// </summary>
/// <remarks>
/// 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. <see cref="ModelReasoningBehavior.UNKNOWN"/>
/// falls back to the model-name heuristics, which is also what every older settings file yields.
/// </remarks>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ModelReasoningBehavior ReasoningBehavior { get; init; }
@ -80,14 +87,3 @@ public readonly record struct Model(string Id, string? DisplayName)
#endregion
}
/// <summary>
/// Describes the default reasoning behavior reported by a provider's model catalog.
/// </summary>
public enum ModelReasoningBehavior
{
UNKNOWN,
OPTIONAL,
DEFAULT_ON,
ALWAYS_ON,
}

View File

@ -0,0 +1,32 @@
namespace AIStudio.Provider;
/// <summary>
/// Describes the default reasoning behavior reported by a provider's model catalog.
/// </summary>
/// <remarks>
/// A catalog which reports this is more reliable than our model-name heuristics, so
/// <see cref="Model.ReasoningBehavior"/> takes precedence over them. Not every provider
/// reports it, which is what <see cref="UNKNOWN"/> stands for.
/// </remarks>
public enum ModelReasoningBehavior
{
/// <summary>
/// The catalog said nothing about reasoning. The model-name heuristics decide.
/// </summary>
UNKNOWN,
/// <summary>
/// The model can reason, but does not unless the request asks for it.
/// </summary>
OPTIONAL,
/// <summary>
/// The model reasons unless the request switches it off.
/// </summary>
DEFAULT_ON,
/// <summary>
/// The model always reasons and it cannot be switched off.
/// </summary>
ALWAYS_ON,
}

View File

@ -19,7 +19,7 @@ public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat
public bool ContainsContent() => this.Choices.Count > 0;
/// <inheritdoc />
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

View File

@ -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,

View File

@ -5,20 +5,37 @@ namespace AIStudio.Provider.OpenAI;
/// </summary>
/// <param name="Type">The type of the response.</param>
/// <param name="Delta">The delta content of the response.</param>
/// <param name="SummaryIndex">The reasoning summary part this line belongs to.</param>
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";
/// <summary>
/// Whether this line starts a reasoning summary part which follows an earlier one.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private bool IsFollowUpSummaryPart => this.Type is SUMMARY_PART_ADDED && this.SummaryIndex > 0;
#region Implementation of IResponseStreamLine
/// <inheritdoc />
public bool ContainsContent() => this.Delta is not null;
public bool ContainsContent() => this.Delta is not null || this.IsFollowUpSummaryPart;
/// <inheritdoc />
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()),
};
//

View File

@ -42,11 +42,23 @@ public sealed record ResponsesResponse
}));
}
public string GetThinkingOutput() => string.Concat(this.Output
/// <summary>
/// The human-readable thinking the model returned.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<string> parts) => string.Join(
$"{Environment.NewLine}{Environment.NewLine}",
parts.Where(part => !string.IsNullOrWhiteSpace(part)));
public IReadOnlyList<Source> GetSources() => this.Output
.Where(x => ReadString(x, "type").Equals("message", StringComparison.Ordinal))
.SelectMany(ReadContentItems)

View File

@ -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.
/// </remarks>
public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> baseInput, IDictionary<string, object> apiParameters, IList<object> providerTools,
public sealed class ResponsesToolCallingAdapter(Model chatModel, bool isReasoningModel, IList<object> baseInput, IDictionary<string, object> apiParameters, IList<object> providerTools,
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
{
@ -50,7 +50,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> 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 sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
/// </summary>
/// <remarks>
/// 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.<br/><br/>
/// Only a reasoning model gets this, because the include is meaningless for every other one.
/// </remarks>
private static IDictionary<string, object> IncludeEncryptedReasoning(IDictionary<string, object> apiParameters)
{
@ -111,11 +112,15 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
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<object> values => values.ToList(),
string value => new List<object> { value },
_ => [],
var value => [value],
};
if (!includedOutput.Any(value => string.Equals(value as string, ENCRYPTED_REASONING_INCLUDE, StringComparison.Ordinal)))

View File

@ -8,6 +8,9 @@ namespace AIStudio.Provider.OpenRouter;
/// <param name="Reasoning">The model's provider-reported reasoning behavior.</param>
public readonly record struct OpenRouterModel(string Id, string? Name, OpenRouterReasoning? Reasoning)
{
/// <summary>
/// Converts the catalog entry into the model the rest of the app works with.
/// </summary>
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
},
};
}
/// <summary>
/// The reasoning defaults returned for a model by OpenRouter's model catalog.
/// </summary>
/// <param name="DefaultEnabled">Whether reasoning is enabled when the request does not configure it.</param>
/// <param name="Mandatory">Whether reasoning cannot be disabled for the model.</param>
public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory);

View File

@ -0,0 +1,12 @@
namespace AIStudio.Provider.OpenRouter;
/// <summary>
/// The reasoning defaults returned for a model by OpenRouter's model catalog.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="DefaultEnabled">Whether reasoning is enabled when the request does not configure it.</param>
/// <param name="Mandatory">Whether reasoning cannot be disabled for the model.</param>
public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory);

View File

@ -15,7 +15,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint
public bool ContainsContent() => this != default && this.Choices.Count > 0;
/// <inheritdoc />
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);
/// <inheritdoc />
public bool ContainsSources() => this != default && this.SearchResults.Count > 0;

View File

@ -73,9 +73,7 @@ public static partial class ProviderExtensions
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 @@ public static partial class ProviderExtensions
LLMProviders.HELMHOLTZ or
LLMProviders.GWDG => MergeReasoningStates(
GetOpenAICompatibleReasoningState(parameters),
GetReasoningEffortState(parameters),
GetQwenReasoningState(parameters),
GetGoogleReasoningState(parameters)),
@ -119,14 +116,12 @@ public static partial class ProviderExtensions
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 @@ public static partial class ProviderExtensions
/// <returns>The detected reasoning configuration state.</returns>
/// <remarks>
/// OpenAI-compatible providers commonly use a nested <c>reasoning</c> object and/or
/// a top-level <c>reasoning_effort</c> parameter.
/// a top-level <c>reasoning_effort</c> parameter. Both are covered here, so a caller
/// does not merge <see cref="GetReasoningEffortState"/> on top of this one again.
/// </remarks>
private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary<string, object> parameters)
{

View File

@ -396,10 +396,12 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
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

View File

@ -51,7 +51,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
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;
}