Fixed reasoning tokens inflating the exact token count

This commit is contained in:
Thorsten Sommer committed 2026-09-23 18:53:01 +02:00
1 parent c23ce9e12a
commit 3277f20f60
12 files changed
+172 -104

No files matched your search

@@ -1128,7 +1128,7 @@ public abstract class BaseProvider : IProvider, ISecretId
{
yield return providerResponse.ContainsContent()
? providerResponse.GetContent() with { Usage = usage }
: new(string.Empty, [], usage);
: new(string.Empty, [], Usage: usage);
continue;
}
@@ -5,9 +5,12 @@ namespace AIStudio.Provider.OpenAI;
/// What an OpenAI-compatible provider reports a chat completion cost.
/// </summary>
/// <remarks>
/// Every number is optional because this is somebody else's JSON: the block arrives only when the
/// The number is optional because this is somebody else's JSON: the block arrives only when the
/// request asked for it, and the providers which follow the shape loosely leave fields out. Reading
/// it is one thing, believing it another -- TokenUsage.OfReported decides that.
///
/// The block states more than this, the completion and its reasoning share among it. Those are left
/// unread on purpose, for the reason given at TokenUsage: no later request carries them.
/// </remarks>
public sealed record ChatCompletionUsage
{
@@ -16,11 +19,6 @@ public sealed record ChatCompletionUsage
/// </summary>
public int? PromptTokens { get; init; }
/// <summary>
/// What the model wrote in answer.
/// </summary>
public int? CompletionTokens { get; init; }
/// <summary>
/// States what this block reports, as far as it can be believed.
/// </summary>
@@ -29,5 +27,5 @@ public sealed record ChatCompletionUsage
/// so that what counts as believable is decided in a single place.
/// </remarks>
/// <returns>The usage, or TokenUsage.UNKNOWN when the block states nothing usable.</returns>
public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens, this.CompletionTokens);
public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens);
}
+14 -27
View File
@@ -1,13 +1,18 @@
namespace AIStudio.Provider;
/// <summary>
/// What a provider said one request actually cost, in tokens.
/// What a provider said one request actually carried, in tokens.
/// </summary>
/// <remarks>
/// The counterpart to what the app counts for itself: the app estimates what the next request will
/// cost, while this is what the provider charged for the last one. Two different statements, and
/// cost, while this is what the provider counted for the last one. Two different statements, and
/// this one is the only exact one of the two.
///
/// Only the prompt is kept. Providers state what the answer cost as well, but that number includes
/// the model's reasoning and whatever the model wrote between think tags, and neither of them ever
/// becomes part of the answer's text. No later request carries them, so the number has no place in
/// a statement about those requests.
///
/// Nothing here says "unknown" with a zero. The default value of this type is unknown, which is the
/// right answer for every provider which reports nothing, and a counted request can never cost zero
/// prompt tokens because the factory below refuses to build one.
@@ -20,7 +25,7 @@ public readonly record struct TokenUsage
public static readonly TokenUsage UNKNOWN = new();
/// <summary>
/// Whether a provider reported anything at all. When false, the numbers are meaningless.
/// Whether a provider reported anything at all. When false, the number is meaningless.
/// </summary>
public bool IsKnown { get; private init; }
@@ -30,37 +35,23 @@ public readonly record struct TokenUsage
/// </summary>
public int PromptTokens { get; private init; }
/// <summary>
/// What the model wrote in answer.
/// </summary>
public int CompletionTokens { get; private init; }
/// <summary>
/// What the whole exchange cost, which is what the next request carries as its history.
/// </summary>
public int TotalTokens => this.PromptTokens + this.CompletionTokens;
/// <summary>
/// States what a provider reported.
/// </summary>
/// <remarks>
/// A completion of zero tokens is a real answer: a model which was cut off before writing
/// anything still cost its prompt. A prompt of zero is not, because there is no request
/// without one, and a provider sending it means we read the wrong field.
/// A prompt of zero is not a report, because there is no request without one, and a provider
/// sending it means we read the wrong field.
/// </remarks>
/// <param name="promptTokens">What the request carried. Has to be greater than zero.</param>
/// <param name="completionTokens">What the answer cost. Zero or more.</param>
/// <returns>The usage.</returns>
public static TokenUsage Of(int promptTokens, int completionTokens)
public static TokenUsage Of(int promptTokens)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(promptTokens);
ArgumentOutOfRangeException.ThrowIfNegative(completionTokens);
return new()
{
IsKnown = true,
PromptTokens = promptTokens,
CompletionTokens = completionTokens,
};
}
@@ -68,14 +59,10 @@ public readonly record struct TokenUsage
/// States what a provider reported, or unknown when it reported nothing usable.
/// </summary>
/// <remarks>
/// For the reading side, where the numbers come out of somebody else's JSON: a missing field, a
/// null, or a zero prompt all mean the same thing there, and none of them is worth an exception.
/// For the reading side, where the number comes out of somebody else's JSON: a missing field, a
/// null, or a zero all mean the same thing there, and none of them is worth an exception.
/// </remarks>
/// <param name="promptTokens">What the request carried, as the provider stated it.</param>
/// <param name="completionTokens">What the answer cost, as the provider stated it.</param>
/// <returns>The usage, or UNKNOWN.</returns>
public static TokenUsage OfReported(int? promptTokens, int? completionTokens) =>
promptTokens is > 0
? Of(promptTokens.Value, completionTokens is > 0 ? completionTokens.Value : 0)
: UNKNOWN;
public static TokenUsage OfReported(int? promptTokens) => promptTokens is > 0 ? Of(promptTokens.Value) : UNKNOWN;
}