This commit is contained in:
Peer Hogeterp 2026-09-10 15:57:27 +00:00 committed by GitHub
commit 6fc8353e6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 611 additions and 66 deletions

View File

@ -3244,6 +3244,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"
@ -3325,6 +3331,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"

View File

@ -31,6 +31,23 @@
</MudButton>
</MudTooltip>
}
@if (this.HasThinking)
{
<MudTooltip Text="@this.GetThinkingTooltip()" Placement="Placement.Bottom">
<MudButton Variant="Variant.Outlined"
Color="Color.Default"
Size="Size.Small"
Class="px-2 py-1 rounded-pill"
Style="min-width:auto; border-width:1px; text-transform:none;"
OnClick="@this.ToggleThinking">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Psychology" Color="Color.Default" Size="Size.Small" />
<MudText Typo="Typo.body2">@T("Thinking")</MudText>
<MudIcon Icon="@(this.showThinking ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Size="Size.Small" />
</MudStack>
</MudButton>
</MudTooltip>
}
</MudStack>
</CardHeaderContent>
<CardHeaderActions>
@ -193,6 +210,14 @@
</MudPaper>
}
@if (this.HasThinking && this.showThinking)
{
<MudPaper Class="pa-3 mb-3 border rounded-lg" Style="border-width:1px;">
<MudText Typo="Typo.subtitle2" Class="mb-2">@T("Thinking")</MudText>
<MudText Typo="Typo.body2" Style="white-space: pre-wrap; overflow-wrap: anywhere;">@textContent.Thinking</MudText>
</MudPaper>
}
if (textContent.InitialRemoteWait)
{
<MudSkeleton Width="30%" Height="42px;"/>
@ -201,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,6 +127,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private bool hasActiveMathContainer;
private bool isDisposed;
private bool showToolTrace;
private bool showThinking;
private readonly HashSet<int> expandedToolInvocations = [];
/// <summary>
@ -139,6 +140,10 @@ public partial class ContentBlockComponent : MSGComponentBase
/// </remarks>
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);
/// <summary>
/// The tables this block holds so that the export menu can offer each of them.
/// </summary>
@ -305,11 +310,14 @@ public partial class ContentBlockComponent : MSGComponentBase
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 @@ public partial class ContentBlockComponent : MSGComponentBase
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 @@ public partial class ContentBlockComponent : MSGComponentBase
await this.DisposeMathContainerIfNeededAsync();
}
}
}

View File

@ -16,6 +16,9 @@ namespace AIStudio.Chat;
/// </summary>
public sealed class ContentText : IContent
{
private const string OPEN_THINK_TAG = "<think>";
private const string CLOSE_THINK_TAG = "</think>";
private static readonly ILogger<ContentText> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ContentText>();
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText));
@ -26,6 +29,12 @@ public sealed class ContentText : IContent
/// </summary>
private static readonly TimeSpan MIN_TIME = TimeSpan.FromSeconds(3);
[JsonIgnore]
private ThinkTagStreamState thinkTagStreamState;
[JsonIgnore]
private readonly StringBuilder thinkTagBuffer = new();
#region Implementation of IContent
/// <inheritdoc />
@ -118,15 +127,18 @@ public sealed class ContentText : IContent
if (token.IsCancellationRequested)
break;
// Stop the waiting animation:
this.InitialRemoteWait = false;
this.IsStreaming = true;
// Add the response to the text:
this.Text += contentStreamChunk;
// Add the response to the content:
this.ApplyStreamChunk(contentStreamChunk);
// Merge the sources:
this.Sources.MergeSources(contentStreamChunk.Sources);
//
// 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:
@ -160,7 +172,7 @@ public sealed class ContentText : IContent
}
finally
{
this.Text = this.Text.RemoveThinkTags().Trim();
this.FinalizeStreamContent();
// Inform the UI that the streaming is done:
await this.StreamingDone();
@ -253,6 +265,7 @@ public sealed class ContentText : IContent
public IContent DeepClone() => new ContentText
{
Text = this.Text,
Thinking = this.Thinking,
InitialRemoteWait = this.InitialRemoteWait,
IsStreaming = this.IsStreaming,
Sources = [..this.Sources],
@ -407,4 +420,123 @@ public sealed class ContentText : IContent
/// The text content.
/// </summary>
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// Human-readable thinking content exposed by the provider.
/// </summary>
public string Thinking { get; set; } = string.Empty;
/// <summary>
/// Applies one provider stream chunk to this content.
/// </summary>
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);
}
/// <summary>
/// Completes parsing of provider content and normalizes the displayed values.
/// </summary>
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,
}
}

View File

@ -3246,6 +3246,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"
@ -3327,6 +3333,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"

View File

@ -3246,6 +3246,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"
@ -3327,6 +3333,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"

View File

@ -36,6 +36,20 @@ public sealed record AnthropicResponse
.Where(x => ReadString(x, "type").Equals("text", StringComparison.Ordinal))
.Select(x => ReadString(x, "text")));
/// <summary>
/// The human-readable thinking the model returned, with redacted blocks omitted.
/// </summary>
/// <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)
{
if (item.ValueKind is not JsonValueKind.Object ||
@ -45,4 +59,4 @@ public sealed record AnthropicResponse
return property.GetString() ?? string.Empty;
}
}
}

View File

@ -55,6 +55,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
this.lastResponse = response;
return new ToolCallingRound(
response.GetTextOutput(),
response.GetThinkingOutput(),
response.GetToolUses()
.Select(toolUse => new ToolCallingRequestedCall(
toolUse.Id,
@ -85,4 +86,4 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
Content = content,
IsError = isError,
});
}
}

View File

@ -6,4 +6,5 @@ namespace AIStudio.Provider.Anthropic;
/// </summary>
/// <param name="Type">The type of the delta.</param>
/// <param name="Text">The text of the delta.</param>
public readonly record struct Delta(string Type, string Text);
/// <param name="Thinking">The human-readable thinking delta.</param>
public readonly record struct Delta(string Type, string Text, string Thinking);

View File

@ -10,10 +10,15 @@ namespace AIStudio.Provider.Anthropic;
public readonly record struct ResponseStreamLine(string Type, int Index, Delta Delta) : IResponseStreamLine
{
/// <inheritdoc />
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));
/// <inheritdoc />
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, []),
};
#region Implementation of IAnnotationStreamLine
@ -29,4 +34,4 @@ public readonly record struct ResponseStreamLine(string Type, int Index, Delta D
public IList<ISource> GetSources() => [];
#endregion
}
}

View File

@ -1048,6 +1048,21 @@ public abstract class BaseProvider : IProvider, ISecretId
//
if (jsonData.StartsWith("""
{"type":"response.output_text.delta"
""", StringComparison.InvariantCulture) ||
jsonData.StartsWith("""
{"type":"response.reasoning_summary_text.delta"
""", 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;
@ -1606,4 +1621,4 @@ public abstract class BaseProvider : IProvider, ISecretId
return true;
}
}
}

View File

@ -5,7 +5,8 @@ namespace AIStudio.Provider;
/// </summary>
/// <param name="Content">The text content of the chunk.</param>
/// <param name="Sources">The list of sources associated with the chunk.</param>
public sealed record ContentStreamChunk(string Content, 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 = "")
{
/// <summary>
/// Implicit conversion to string.
@ -13,4 +14,4 @@ public sealed record ContentStreamChunk(string Content, IList<ISource> Sources)
/// <param name="chunk">The content stream chunk.</param>
/// <returns>The text content of the chunk.</returns>
public static implicit operator string(ContentStreamChunk chunk) => chunk.Content;
}
}

View File

@ -1,7 +1,17 @@
using System.Text.Json;
using AIStudio.Provider.OpenAI;
namespace AIStudio.Provider.Fireworks;
/// <summary>
/// The delta text of a choice.
/// </summary>
/// <param name="Content">The content of the delta text.</param>
public readonly record struct Delta(string Content);
/// <param name="ReasoningContent">OpenAI-compatible reasoning content.</param>
/// <param name="Reasoning">OpenRouter-compatible reasoning content.</param>
/// <param name="ReasoningDetails">Structured reasoning details.</param>
public readonly record struct Delta(string Content, string? ReasoningContent, string? Reasoning, IList<JsonElement>? ReasoningDetails)
{
public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}

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, []);
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<ISource> GetSources() => [];
#endregion
}
}

View File

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Provider;
@ -20,6 +22,19 @@ public readonly record struct Model(string Id, string? DisplayName)
/// </summary>
public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null);
/// <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; }
/// <summary>
/// Checks if this model is the system-configured placeholder.
/// </summary>
@ -71,4 +86,4 @@ public readonly record struct Model(string Id, string? DisplayName)
public override int GetHashCode() => this.Id?.GetHashCode(StringComparison.Ordinal) ?? 0;
#endregion
}
}

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

@ -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<JsonElement>? ReasoningDetails { get; init; }
public IList<ChatCompletionToolCall> ToolCalls { get; init; } = [];
}

View File

@ -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<JsonElement>? ReasoningDetails { get; init; }
[JsonIgnore]
public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}

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, []);
public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, [], this.Choices[0].Delta.Thinking);
#region Implementation of IAnnotationStreamLine
@ -42,4 +42,4 @@ public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat
public IList<ISource> GetSources() => [];
#endregion
}
}

View File

@ -15,5 +15,11 @@ public sealed record ChatCompletionResponseMessage
public string? ReasoningContent { get; init; }
public string? Reasoning { get; init; }
public IList<JsonElement>? ReasoningDetails { get; init; }
public IList<ChatCompletionToolCall?>? ToolCalls { get; init; }
public string GetThinkingOutput() => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}

View File

@ -72,6 +72,7 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
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 sealed class ChatCompletionToolCallingAdapter<TRequest>(
{
Content = this.lastResponseMessage?.RawContent,
ReasoningContent = this.lastResponseMessage?.ReasoningContent,
Reasoning = this.lastResponseMessage?.Reasoning,
ReasoningDetails = this.lastResponseMessage?.ReasoningDetails,
ToolCalls = this.lastToolCalls,
});
@ -168,4 +171,4 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
}
private readonly record struct PreparedChatCompletionToolCall(ChatCompletionToolCall ToolCall, bool IsValid);
}
}

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,17 +5,38 @@ 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() => new(this.Delta ?? string.Empty, this.GetSources());
public ContentStreamChunk GetContent() => this.Type switch
{
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()),
};
//
// Please note that there are multiple options where LLM providers might stream sources:
@ -36,4 +57,4 @@ public record ResponsesDeltaStreamLine(
public IList<ISource> GetSources() => [];
#endregion
}
}

View File

@ -42,6 +42,23 @@ public sealed record ResponsesResponse
}));
}
/// <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,10 +11,12 @@ 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
{
private const string ENCRYPTED_REASONING_INCLUDE = "reasoning.encrypted_content";
private readonly List<object> internalItems = [];
private ResponsesResponse? lastResponse;
@ -48,7 +50,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
Stream = false,
Store = false,
Tools = includeTools ? this.effectiveProviderTools : [],
AdditionalApiParameters = apiParameters,
AdditionalApiParameters = isReasoningModel ? IncludeEncryptedReasoning(apiParameters) : apiParameters,
}, token);
if (response is null)
@ -57,6 +59,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
this.lastResponse = response;
return new ToolCallingRound(
response.GetTextOutput(),
response.GetThinkingOutput(),
response.GetFunctionCalls()
.Select(call => new ToolCallingRequestedCall(
call.CallId ?? string.Empty,
@ -91,6 +94,42 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
Output = content,
});
/// <summary>
/// Request encrypted reasoning content without replacing any additional output data selected by the user.
/// </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.<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)
{
var result = new Dictionary<string, object>(apiParameters);
var includeKey = result.Keys.FirstOrDefault(key => key.Equals("include", StringComparison.OrdinalIgnoreCase));
if (includeKey is null)
{
result["include"] = new List<object> { ENCRYPTED_REASONING_INCLUDE };
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(),
var value => [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<object> BuildEffectiveProviderTools(IList<object> providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools)
{
var localFunctionNames = runnableTools
@ -102,4 +141,4 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
.Concat(runnableTools.Select(x => (object)ProviderToolAdapters.ToResponsesTool(x.Definition)))
.ToList();
}
}
}

View File

@ -0,0 +1,46 @@
using System.Text.Json;
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// Reads human-readable thinking from OpenAI-compatible response fields.
/// </summary>
internal static class ThinkingContent
{
public static string Get(string? reasoningContent, string? reasoning, IEnumerable<JsonElement>? reasoningDetails)
{
if (!string.IsNullOrEmpty(reasoningContent))
return reasoningContent;
if (!string.IsNullOrEmpty(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;
}
}

View File

@ -5,4 +5,20 @@ namespace AIStudio.Provider.OpenRouter;
/// </summary>
/// <param name="Id">The model's ID.</param>
/// <param name="Name">The model's human-readable display name.</param>
public readonly record struct OpenRouterModel(string Id, string? Name);
/// <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
{
{ Mandatory: true } => ModelReasoningBehavior.ALWAYS_ON,
{ DefaultEnabled: true } => ModelReasoningBehavior.DEFAULT_ON,
not null => ModelReasoningBehavior.OPTIONAL,
_ => ModelReasoningBehavior.UNKNOWN,
},
};
}

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

@ -116,7 +116,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
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 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
return this.LoadModelsResponse<OpenRouterModelsResponse>(
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) =>
{

View File

@ -1,7 +1,17 @@
using System.Text.Json;
using AIStudio.Provider.OpenAI;
namespace AIStudio.Provider.Perplexity;
/// <summary>
/// The delta text of a choice.
/// </summary>
/// <param name="Content">The content of the delta text.</param>
public readonly record struct Delta(string Content);
/// <param name="ReasoningContent">OpenAI-compatible reasoning content.</param>
/// <param name="Reasoning">OpenRouter-compatible reasoning content.</param>
/// <param name="ReasoningDetails">Structured reasoning details.</param>
public readonly record struct Delta(string Content, string? ReasoningContent, string? Reasoning, IList<JsonElement>? ReasoningDetails)
{
public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}

View File

@ -15,11 +15,11 @@ 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.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;
/// <inheritdoc />
public IList<ISource> GetSources() => this.SearchResults.Cast<ISource>().ToList();
}
}

View File

@ -57,7 +57,29 @@ public static partial class ProviderExtensions
_ => GetModelCapabilitiesOpenSource(bareModel),
};
return NormalizeForGateway(capabilities);
return ApplyGatewayReasoningBehavior(NormalizeForGateway(capabilities), model.ReasoningBehavior);
}
/// <summary>
/// Applies provider-reported reasoning behavior in preference to model-name heuristics.
/// </summary>
private static List<Capability> ApplyGatewayReasoningBehavior(List<Capability> 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;
}
/// <summary>
@ -78,4 +100,4 @@ public static partial class ProviderExtensions
return capabilities;
}
}
}

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,25 +137,60 @@ 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)
{
var reasoningState = ReasoningConfigurationState.NOT_CONFIGURED;
var states = new List<ReasoningConfigurationState>();
if (TryGetParameter(parameters, "reasoning", out var reasoning))
{
reasoningState = reasoning switch
if (reasoning is IDictionary<string, object> reasoningObject)
{
IDictionary<string, object> reasoningObject when TryGetParameter(reasoningObject, "effort", out var effort) => GetLevelState(effort),
IDictionary<string, object> reasoningObject when TryGetParameter(reasoningObject, "summary", out var summary) => GetLevelState(summary),
IDictionary<string, object> => 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);
}
/// <summary>
/// Detect summary settings that imply reasoning is enabled.
/// </summary>
/// <remarks>
/// Turning a summary off controls visibility only and does not disable the model's reasoning.
/// </remarks>
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,
};
/// <summary>
/// Detect a top-level <c>reasoning_effort</c> parameter.
/// </summary>
@ -444,6 +474,7 @@ public static partial class ProviderExtensions
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 +547,4 @@ public static partial class ProviderExtensions
value = parameters[foundKey];
return true;
}
}
}

View File

@ -273,7 +273,6 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
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 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
var aiText = request.AIText;
aiText.InitialRemoteWait = false;
aiText.IsStreaming = false;
aiText.Text = aiText.Text.RemoveThinkTags().Trim();
aiText.FinalizeStreamContent();
RemoveEmptyAIResponse(state);
@ -367,7 +366,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
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
@ -397,10 +396,11 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
return false;
var aiText = state.ChatGenerationRequest.AIText;
aiText.InitialRemoteWait = false;
aiText.IsStreaming = true;
aiText.Text += contentStreamChunk;
aiText.Sources.MergeSources(contentStreamChunk.Sources);
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)
{
@ -519,4 +519,4 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
logger.LogWarning("Skipping AI request because model '{ModelId}' is not available from '{ProviderInstanceName}' (provider={ProviderType}).", chatModel.Id, provider.InstanceName, provider.Provider);
return false;
}
}
}

View File

@ -28,6 +28,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
var toolCallCount = 0;
var toolResultCharacterCount = 0L;
var toolSources = new List<Source>();
var hasThinkingOutput = false;
while (true)
{
@ -47,6 +48,13 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
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 sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
private static string GetDisplayName(ToolCallingLoopContext context, string toolName) => context.RunnableTools
.FirstOrDefault(tool => tool.Definition.Function.Name.Equals(toolName, StringComparison.Ordinal))
.Implementation?.GetDisplayName() ?? toolName;
}
}

View File

@ -5,6 +5,7 @@ namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// longer depends on the provider API it came from.
/// </summary>
/// <param name="TextOutput">The text the model produced, empty when it only requested tool calls.</param>
/// <param name="ThinkingOutput">The human-readable thinking the provider exposed.</param>
/// <param name="Calls">The tool calls the model requested, empty when it answered instead.</param>
/// <param name="Sources">Sources the provider itself attached, such as those of a provider-native web search.</param>
public sealed record ToolCallingRound(string TextOutput, IReadOnlyList<ToolCallingRequestedCall> Calls, IReadOnlyList<ISource> Sources);
public sealed record ToolCallingRound(string TextOutput, string ThinkingOutput, IReadOnlyList<ToolCallingRequestedCall> Calls, IReadOnlyList<ISource> Sources);

View File

@ -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.
- Added support for several drop areas on the same page. More complex assistants can now receive files or folders by drag and drop at more than one place.