Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2236,6 +2236,14 @@
{
"Member": "long? Microsoft.Extensions.AI.UsageDetails.TotalTokenCount { get; set; }",
"Stage": "Stable"
},
{
"Member": "long? Microsoft.Extensions.AI.UsageDetails.CachedInputTokenCount { get; set; }",
"Stage": "Stable"
},
{
"Member": "long? Microsoft.Extensions.AI.UsageDetails.ReasoningTokenCount { get; set; }",
"Stage": "Stable"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ public class UsageDetails
/// <summary>Gets or sets the total number of tokens used to produce the response.</summary>
public long? TotalTokenCount { get; set; }

/// <summary>
/// Gets or sets the number of input tokens that were read from a cache.
/// </summary>
/// <remarks>
/// Cached input tokens should be counted as part of <see cref="InputTokenCount"/>.
/// </remarks>
public long? CachedInputTokenCount { get; set; }

/// <summary>
/// Gets or sets the number of "reasoning" / "thinking" tokens used internally
/// by the model.
/// </summary>
/// <remarks>
/// Reasoning tokens should be counted as part of <see cref="OutputTokenCount"/>.
/// </remarks>
public long? ReasoningTokenCount { get; set; }

/// <summary>Gets or sets a dictionary of additional usage counts.</summary>
/// <remarks>
/// All values set here are assumed to be summable. For example, when middleware makes multiple calls to an underlying
Expand All @@ -38,6 +55,8 @@ public void Add(UsageDetails usage)
InputTokenCount = NullableSum(InputTokenCount, usage.InputTokenCount);
OutputTokenCount = NullableSum(OutputTokenCount, usage.OutputTokenCount);
TotalTokenCount = NullableSum(TotalTokenCount, usage.TotalTokenCount);
CachedInputTokenCount = NullableSum(CachedInputTokenCount, usage.CachedInputTokenCount);
ReasoningTokenCount = NullableSum(ReasoningTokenCount, usage.ReasoningTokenCount);

if (usage.AdditionalCounts is { } countsToAdd)
{
Expand Down Expand Up @@ -80,6 +99,16 @@ internal string DebuggerDisplay
parts.Add($"{nameof(TotalTokenCount)} = {total}");
}

if (CachedInputTokenCount is { } cached)
{
parts.Add($"{nameof(CachedInputTokenCount)} = {cached}");
}

if (ReasoningTokenCount is { } reasoning)
{
parts.Add($"{nameof(ReasoningTokenCount)} = {reasoning}");
}

if (AdditionalCounts is { } additionalCounts)
{
foreach (var entry in additionalCounts)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,8 @@ private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage)
InputTokenCount = tokenUsage.InputTokenCount,
OutputTokenCount = tokenUsage.OutputTokenCount,
TotalTokenCount = tokenUsage.TotalTokenCount,
CachedInputTokenCount = tokenUsage.InputTokenDetails?.CachedTokenCount,
ReasoningTokenCount = tokenUsage.OutputTokenDetails?.ReasoningTokenCount,
AdditionalCounts = [],
};

Expand All @@ -653,13 +655,11 @@ private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage)
{
const string InputDetails = nameof(ChatTokenUsage.InputTokenDetails);
counts.Add($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", inputDetails.AudioTokenCount);
counts.Add($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", inputDetails.CachedTokenCount);
}

if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails outputDetails)
{
const string OutputDetails = nameof(ChatTokenUsage.OutputTokenDetails);
counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount);
counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", outputDetails.AudioTokenCount);
counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AcceptedPredictionTokenCount)}", outputDetails.AcceptedPredictionTokenCount);
counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.RejectedPredictionTokenCount)}", outputDetails.RejectedPredictionTokenCount);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1143,19 +1143,9 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera
InputTokenCount = usage.InputTokenCount,
OutputTokenCount = usage.OutputTokenCount,
TotalTokenCount = usage.TotalTokenCount,
CachedInputTokenCount = usage.InputTokenDetails?.CachedTokenCount,
ReasoningTokenCount = usage.OutputTokenDetails?.ReasoningTokenCount,
};

if (usage.InputTokenDetails is { } inputDetails)
{
ud.AdditionalCounts ??= [];
ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount);
}

if (usage.OutputTokenDetails is { } outputDetails)
{
ud.AdditionalCounts ??= [];
ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount);
}
}

return ud;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ public void Serialization_Roundtrips()
{
InputTokenCount = 10,
OutputTokenCount = 20,
TotalTokenCount = 30
TotalTokenCount = 30,
CachedInputTokenCount = 5,
ReasoningTokenCount = 8
});

var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions);
Expand All @@ -77,5 +79,7 @@ public void Serialization_Roundtrips()
Assert.Equal(content.Details.InputTokenCount, deserializedContent.Details.InputTokenCount);
Assert.Equal(content.Details.OutputTokenCount, deserializedContent.Details.OutputTokenCount);
Assert.Equal(content.Details.TotalTokenCount, deserializedContent.Details.TotalTokenCount);
Assert.Equal(content.Details.CachedInputTokenCount, deserializedContent.Details.CachedInputTokenCount);
Assert.Equal(content.Details.ReasoningTokenCount, deserializedContent.Details.ReasoningTokenCount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Text.Json;
using Xunit;

namespace Microsoft.Extensions.AI;

public class UsageDetailsTests
{
[Fact]
public void Constructor_PropsDefault()
{
UsageDetails details = new();
Assert.Null(details.InputTokenCount);
Assert.Null(details.OutputTokenCount);
Assert.Null(details.TotalTokenCount);
Assert.Null(details.CachedInputTokenCount);
Assert.Null(details.ReasoningTokenCount);
Assert.Null(details.AdditionalCounts);
}

[Fact]
public void Properties_Roundtrip()
{
UsageDetails details = new()
{
InputTokenCount = 10,
OutputTokenCount = 20,
TotalTokenCount = 30,
CachedInputTokenCount = 5,
ReasoningTokenCount = 8,
AdditionalCounts = new() { ["custom"] = 100 }
};

Assert.Equal(10, details.InputTokenCount);
Assert.Equal(20, details.OutputTokenCount);
Assert.Equal(30, details.TotalTokenCount);
Assert.Equal(5, details.CachedInputTokenCount);
Assert.Equal(8, details.ReasoningTokenCount);
Assert.NotNull(details.AdditionalCounts);
Assert.Equal(100, details.AdditionalCounts["custom"]);
}

[Fact]
public void Add_NullUsage_Throws()
{
UsageDetails details = new();
Assert.Throws<ArgumentNullException>("usage", () => details.Add(null!));
}

[Fact]
public void Add_SumsAllProperties()
{
UsageDetails details1 = new()
{
InputTokenCount = 10,
OutputTokenCount = 20,
TotalTokenCount = 30,
CachedInputTokenCount = 5,
ReasoningTokenCount = 8,
};

UsageDetails details2 = new()
{
InputTokenCount = 15,
OutputTokenCount = 25,
TotalTokenCount = 40,
CachedInputTokenCount = 7,
ReasoningTokenCount = 12,
};

details1.Add(details2);

Assert.Equal(25, details1.InputTokenCount);
Assert.Equal(45, details1.OutputTokenCount);
Assert.Equal(70, details1.TotalTokenCount);
Assert.Equal(12, details1.CachedInputTokenCount);
Assert.Equal(20, details1.ReasoningTokenCount);
}

[Fact]
public void Add_WithNullValues_HandlesCorrectly()
{
UsageDetails details1 = new()
{
InputTokenCount = 10,
CachedInputTokenCount = 5,
};

UsageDetails details2 = new()
{
OutputTokenCount = 25,
ReasoningTokenCount = 12,
};

details1.Add(details2);

Assert.Equal(10, details1.InputTokenCount);
Assert.Equal(25, details1.OutputTokenCount);
Assert.Null(details1.TotalTokenCount);
Assert.Equal(5, details1.CachedInputTokenCount);
Assert.Equal(12, details1.ReasoningTokenCount);
}

[Fact]
public void Add_FromNullToValue_SetsValue()
{
UsageDetails details1 = new();

UsageDetails details2 = new()
{
CachedInputTokenCount = 5,
ReasoningTokenCount = 10,
};

details1.Add(details2);

Assert.Equal(5, details1.CachedInputTokenCount);
Assert.Equal(10, details1.ReasoningTokenCount);
}

[Fact]
public void Add_AdditionalCounts_MergesCorrectly()
{
UsageDetails details1 = new()
{
AdditionalCounts = new() { ["key1"] = 10, ["key2"] = 20 }
};

UsageDetails details2 = new()
{
AdditionalCounts = new() { ["key2"] = 30, ["key3"] = 40 }
};

details1.Add(details2);

Assert.NotNull(details1.AdditionalCounts);
Assert.Equal(10, details1.AdditionalCounts["key1"]);
Assert.Equal(50, details1.AdditionalCounts["key2"]);
Assert.Equal(40, details1.AdditionalCounts["key3"]);
}

[Fact]
public void Serialization_Roundtrips()
{
UsageDetails details = new()
{
InputTokenCount = 10,
OutputTokenCount = 20,
TotalTokenCount = 30,
CachedInputTokenCount = 5,
ReasoningTokenCount = 8,
AdditionalCounts = new() { ["custom"] = 100 }
};

string json = JsonSerializer.Serialize(details, AIJsonUtilities.DefaultOptions);
UsageDetails? deserialized = JsonSerializer.Deserialize<UsageDetails>(json, AIJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.Equal(details.InputTokenCount, deserialized.InputTokenCount);
Assert.Equal(details.OutputTokenCount, deserialized.OutputTokenCount);
Assert.Equal(details.TotalTokenCount, deserialized.TotalTokenCount);
Assert.Equal(details.CachedInputTokenCount, deserialized.CachedInputTokenCount);
Assert.Equal(details.ReasoningTokenCount, deserialized.ReasoningTokenCount);
Assert.NotNull(deserialized.AdditionalCounts);
Assert.Equal(100, deserialized.AdditionalCounts["custom"]);
}

[Fact]
public void Serialization_WithNullProperties_Roundtrips()
{
UsageDetails details = new()
{
InputTokenCount = 10,
OutputTokenCount = 20,
};

string json = JsonSerializer.Serialize(details, AIJsonUtilities.DefaultOptions);
UsageDetails? deserialized = JsonSerializer.Deserialize<UsageDetails>(json, AIJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.Equal(10, deserialized.InputTokenCount);
Assert.Equal(20, deserialized.OutputTokenCount);
Assert.Null(deserialized.TotalTokenCount);
Assert.Null(deserialized.CachedInputTokenCount);
Assert.Null(deserialized.ReasoningTokenCount);
}
}
Loading
Loading