1import type {
2 OrchestrationV2ProviderTurn,
3 OrchestrationV2ProviderTurnTokenUsage,
4 OrchestrationV2ProviderThread,
5 OrchestrationV2TurnItem,
6 ThreadTokenUsageSnapshot,
7} from "@t3tools/contracts";
8import * as DateTime from "effect/DateTime";
9
10function asFiniteNumber(value: unknown): number | null {
11 return typeof value === "number" && Number.isFinite(value) ? value : null;
12}
13
14type NullableContextWindowUsage = {
15 readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key]
16 ? Exclude<ThreadTokenUsageSnapshot[Key], undefined> | null
17 : ThreadTokenUsageSnapshot[Key];
18};
19
20export type ContextWindowSnapshot = NullableContextWindowUsage & {
21 readonly remainingTokens: number | null;
22 readonly usedPercentage: number | null;
23 readonly remainingPercentage: number | null;
24 readonly updatedAt: string;
25};
26
27/** Newest usage report across a thread's provider turns. Snapshots order turns
28 by provider thread, not time, so pick by timestamp. */
29export function latestProviderTurnTokenUsage(
30 turns: ReadonlyArray<Pick<OrchestrationV2ProviderTurn, "tokenUsage">>,
31): OrchestrationV2ProviderTurnTokenUsage | null {
32 let latest: OrchestrationV2ProviderTurnTokenUsage | null = null;
33 for (const { tokenUsage } of turns) {
34 if (tokenUsage === undefined) continue;
35 // Ties go to the later entry, since live upserts append.
36 if (latest === null || Date.parse(tokenUsage.updatedAt) >= Date.parse(latest.updatedAt)) {
37 latest = tokenUsage;
38 }
39 }
40 return latest;
41}
42
43/** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */
44export function deriveLatestContextWindowSnapshot(
45 entries: ReadonlyArray<{
46 readonly item: OrchestrationV2TurnItem;
47 }>,
48 liveUsage?: OrchestrationV2ProviderTurnTokenUsage | null,
49 providerThread?: Pick<OrchestrationV2ProviderThread, "contextUsage" | "updatedAt"> | null,
50): ContextWindowSnapshot | null {
51 if (liveUsage != null) {
52 const usedTokens = Math.max(0, liveUsage.usedTokens);
53 const maxTokens = liveUsage.maxTokens ?? null;
54 const usedPercentage =
55 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
56 const remainingTokens =
57 maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null;
58 const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null;
59 return {
60 usedTokens,
61 totalProcessedTokens: null,
62 maxTokens,
63 remainingTokens,
64 usedPercentage,
65 remainingPercentage,
66 inputTokens: liveUsage.inputTokens ?? null,
67 cachedInputTokens: liveUsage.cachedInputTokens ?? null,
68 outputTokens: liveUsage.outputTokens ?? null,
69 reasoningOutputTokens: liveUsage.reasoningOutputTokens ?? null,
70 lastUsedTokens: null,
71 lastInputTokens: null,
72 lastCachedInputTokens: null,
73 lastOutputTokens: null,
74 lastReasoningOutputTokens: null,
75 toolUses: null,
76 durationMs: null,
77 compactsAutomatically: true,
78 autoCompactThreshold: null,
79 cost: null,
80 updatedAt: liveUsage.updatedAt,
81 };
82 }
83 const providerUsage = providerThread?.contextUsage;
84 const providerUsageUpdatedAt = providerThread?.updatedAt;
85 if (
86 providerUsage !== null &&
87 providerUsage !== undefined &&
88 providerUsageUpdatedAt !== undefined
89 ) {
90 const maxTokens = asFiniteNumber(providerUsage.maxTokens);
91 const usedTokens = providerUsage.usedTokens;
92 const usedPercentage =
93 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
94 return {
95 usedTokens,
96 totalProcessedTokens: asFiniteNumber(providerUsage.totalProcessedTokens),
97 maxTokens,
98 remainingTokens: maxTokens === null ? null : Math.max(0, Math.round(maxTokens - usedTokens)),
99 usedPercentage,
100 remainingPercentage: usedPercentage === null ? null : Math.max(0, 100 - usedPercentage),
101 inputTokens: asFiniteNumber(providerUsage.inputTokens),
102 cachedInputTokens: asFiniteNumber(providerUsage.cachedInputTokens),
103 outputTokens: asFiniteNumber(providerUsage.outputTokens),
104 reasoningOutputTokens: asFiniteNumber(providerUsage.reasoningOutputTokens),
105 lastUsedTokens: asFiniteNumber(providerUsage.lastUsedTokens),
106 lastInputTokens: asFiniteNumber(providerUsage.lastInputTokens),
107 lastCachedInputTokens: asFiniteNumber(providerUsage.lastCachedInputTokens),
108 lastOutputTokens: asFiniteNumber(providerUsage.lastOutputTokens),
109 lastReasoningOutputTokens: asFiniteNumber(providerUsage.lastReasoningOutputTokens),
110 toolUses: asFiniteNumber(providerUsage.toolUses),
111 durationMs: asFiniteNumber(providerUsage.durationMs),
112 compactsAutomatically: providerUsage.compactsAutomatically ?? null,
113 cost: providerUsage.cost ?? null,
114 autoCompactThreshold: providerUsage.autoCompactThreshold ?? null,
115 updatedAt: DateTime.formatIso(providerUsageUpdatedAt),
116 };
117 }
118 for (let index = entries.length - 1; index >= 0; index -= 1) {
119 const entry = entries[index];
120 if (!entry || entry.item.type !== "compaction") {
121 continue;
122 }
123 const payload = entry.item;
124 const usedTokens = asFiniteNumber(payload.afterTokenCount);
125 if (usedTokens === null || usedTokens < 0) {
126 continue;
127 }
128
129 const maxTokens = null;
130 const usedPercentage =
131 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
132 const remainingTokens =
133 maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null;
134 const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null;
135
136 return {
137 usedTokens,
138 totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount),
139 maxTokens,
140 remainingTokens,
141 usedPercentage,
142 remainingPercentage,
143 inputTokens: null,
144 cachedInputTokens: null,
145 outputTokens: null,
146 reasoningOutputTokens: null,
147 lastUsedTokens: null,
148 lastInputTokens: null,
149 lastCachedInputTokens: null,
150 lastOutputTokens: null,
151 lastReasoningOutputTokens: null,
152 toolUses: null,
153 durationMs: null,
154 compactsAutomatically: true,
155 autoCompactThreshold: null,
156 cost: null,
157 updatedAt: DateTime.formatIso(payload.startedAt ?? payload.updatedAt),
158 };
159 }
160
161 return null;
162}
163
164export function formatContextWindowTokens(value: number | null): string {
165 if (value === null || !Number.isFinite(value)) {
166 return "0";
167 }
168 if (value < 1_000) {
169 return `${Math.round(value)}`;
170 }
171 if (value < 10_000) {
172 return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
173 }
174 // Anything from 999,500 rounds to 1000k, so it belongs to the next unit.
175 if (value < 999_500) {
176 return `${Math.round(value / 1_000)}k`;
177 }
178 return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
179}