Add per-answer thinking disclosure

This commit is contained in:
Peer Hogeterp committed 2026-09-10 15:37:43 +02:00
1 parent c7b42bee96
commit bbbcdddc91
29 files changed
+360 -37

No files matched your search

@@ -36,6 +36,13 @@ 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>
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 @@ public sealed record AnthropicResponse
return property.GetString() ?? string.Empty;
}
}
}
@@ -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,
});
}
}
@@ -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);
@@ -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, string.Empty, []),
};
#region Implementation of IAnnotationStreamLine
@@ -29,4 +34,4 @@ public readonly record struct ResponseStreamLine(string Type, int Index, Delta D
public IList<ISource> GetSources() => [];
#endregion
}
}
@@ -1048,6 +1048,12 @@ 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))
{
TDelta? providerResponse;
@@ -1606,4 +1612,4 @@ public abstract class BaseProvider : IProvider, ISecretId
return true;
}
}
}
@@ -4,13 +4,18 @@ 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, IList<ISource> Sources)
public sealed record ContentStreamChunk(string Content, string Thinking, IList<ISource> Sources)
{
public ContentStreamChunk(string content, IList<ISource> sources) : this(content, string.Empty, sources)
{
}
/// <summary>
/// Implicit conversion to string.
/// </summary>
/// <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;
}
}
@@ -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);
}
@@ -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
}
}
@@ -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; } = [];
}
@@ -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);
}
@@ -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
}
}
@@ -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);
}
@@ -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);
}
}
@@ -15,7 +15,11 @@ public record ResponsesDeltaStreamLine(
public bool ContainsContent() => this.Delta is not null;
/// <inheritdoc />
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<ISource> GetSources() => [];
#endregion
}
}
@@ -42,6 +42,11 @@ public sealed record ResponsesResponse
}));
}
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<Source> GetSources() => this.Output
.Where(x => ReadString(x, "type").Equals("message", StringComparison.Ordinal))
.SelectMany(ReadContentItems)
@@ -57,6 +57,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,
@@ -102,4 +103,4 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
.Concat(runnableTools.Select(x => (object)ProviderToolAdapters.ToResponsesTool(x.Definition)))
.ToList();
}
}
}
@@ -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.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;
}
}
@@ -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);
}
@@ -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.Choices[0].Delta.Thinking, this.GetSources());
/// <inheritdoc />
public bool ContainsSources() => this != default && this.SearchResults.Count > 0;
/// <inheritdoc />
public IList<ISource> GetSources() => this.SearchResults.Cast<ISource>().ToList();
}
}