-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathTokenCapChatClient.cs
More file actions
141 lines (120 loc) · 5.05 KB
/
Copy pathTokenCapChatClient.cs
File metadata and controls
141 lines (120 loc) · 5.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
namespace BlogWriter;
/// <summary>
/// Chat-client middleware that enforces a hard cap on cumulative token usage
/// across every model round-trip in the process (including the extra calls made
/// during tool invocation). When the running total exceeds the cap, the
/// application is terminated with an explanatory message rather than continuing
/// to spend tokens.
/// </summary>
public sealed class TokenCapChatClient : DelegatingChatClient
{
// Key used by the OpenAI connector to report reasoning tokens inside
// UsageDetails.AdditionalCounts (there is no dedicated top-level property).
private const string ReasoningTokenCountKey = "OutputTokenDetails.ReasoningTokenCount";
private readonly TokenBudget _budget;
public TokenCapChatClient(IChatClient innerClient, long maxTotalTokens)
: this(innerClient, new TokenBudget(maxTotalTokens))
{
}
private TokenCapChatClient(IChatClient innerClient, TokenBudget budget) : base(innerClient) =>
_budget = budget;
/// <summary>
/// Creates a MAF chat-client middleware factory whose clients share one
/// cumulative process-wide token budget.
/// </summary>
public static Func<IChatClient, IChatClient> CreateSharedFactory(long maxTotalTokens)
{
var budget = new TokenBudget(maxTotalTokens);
return innerClient => new TokenCapChatClient(innerClient, budget);
}
/// <summary>Cumulative token usage observed across every model round-trip so far.</summary>
public TokenUsageSnapshot UsageSnapshot => _budget.UsageSnapshot;
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
ChatResponse response = await base.GetResponseAsync(messages, options, cancellationToken);
Track(response.Usage);
return response;
}
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (ChatResponseUpdate update in
base.GetStreamingResponseAsync(messages, options, cancellationToken))
{
foreach (AIContent content in update.Contents)
{
if (content is UsageContent usageContent)
{
Track(usageContent.Details);
}
}
yield return update;
}
}
private void Track(UsageDetails? usage)
{
_budget.Track(usage);
}
private sealed class TokenBudget
{
private readonly long _maxTotalTokens;
private long _totalTokens;
private long _inputTokens;
private long _outputTokens;
private long _reasoningTokens;
public TokenBudget(long maxTotalTokens)
{
_maxTotalTokens = maxTotalTokens > 0
? maxTotalTokens
: throw new ArgumentOutOfRangeException(nameof(maxTotalTokens), maxTotalTokens, "Token cap must be a positive number.");
}
public TokenUsageSnapshot UsageSnapshot => new(
Interlocked.Read(ref _inputTokens),
Interlocked.Read(ref _outputTokens),
Interlocked.Read(ref _reasoningTokens),
Interlocked.Read(ref _totalTokens));
public void Track(UsageDetails? usage)
{
if (usage is null)
{
return;
}
Interlocked.Add(ref _inputTokens, usage.InputTokenCount ?? 0);
Interlocked.Add(ref _outputTokens, usage.OutputTokenCount ?? 0);
if (usage.AdditionalCounts is { } additionalCounts &&
additionalCounts.TryGetValue(ReasoningTokenCountKey, out long reasoningTokens))
{
Interlocked.Add(ref _reasoningTokens, reasoningTokens);
}
long used = usage.TotalTokenCount ?? 0;
if (used == 0)
{
return;
}
long total = Interlocked.Add(ref _totalTokens, used);
if (total > _maxTotalTokens)
{
throw new TokenCapExceededException(total, _maxTotalTokens);
}
}
}
}
/// <summary>Point-in-time totals of tokens consumed across all model round-trips.</summary>
public readonly record struct TokenUsageSnapshot(long InputTokens, long OutputTokens, long ReasoningTokens, long TotalTokens);
/// <summary>
/// Thrown when cumulative model token usage exceeds the configured cap. Callers
/// catch this to shut down gracefully instead of continuing to spend tokens.
/// </summary>
public sealed class TokenCapExceededException(long tokensUsed, long tokenLimit)
: Exception($"Token cap exceeded: consumed {tokensUsed} tokens, limit is {tokenLimit}.")
{
public long TokensUsed { get; } = tokensUsed;
public long TokenLimit { get; } = tokenLimit;
}