File size: 8,621 Bytes
b8370f5 | 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 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | import Foundation
/// Snapshot of how full the active session's context window is, derived from
/// the newest usage-bearing message plus session/model metadata.
public struct OpenClawChatContextUsage: Equatable, Sendable {
public let usedTokens: Int
public let contextWindowTokens: Int?
public let totalCost: Double?
public var fractionUsed: Double? {
guard let contextWindowTokens, contextWindowTokens > 0 else { return nil }
return min(1, max(0, Double(self.usedTokens) / Double(contextWindowTokens)))
}
public var percentUsed: Int? {
self.fractionUsed.map { Int(($0 * 100).rounded()) }
}
}
enum ChatContextUsageCalculator {
/// Prefers the newest per-run usage (fresh after every reply) and falls
/// back to fresh server-side session totals when no message carries usage.
static func usage(
messages: [OpenClawChatMessage],
sessionEntry: OpenClawChatSessionEntry?,
defaults: OpenClawChatSessionsDefaults?,
modelContextWindow: Int?) -> OpenClawChatContextUsage?
{
let sessionTokens = sessionEntry?.totalTokensFresh == false ? nil : sessionEntry?.totalTokens
let usedTokens = self.latestRunTokens(in: messages) ?? sessionTokens
guard let usedTokens, usedTokens > 0 else { return nil }
let contextWindow = self.positive(sessionEntry?.contextTokens)
?? self.positive(defaults?.contextTokens)
?? self.positive(modelContextWindow)
return OpenClawChatContextUsage(
usedTokens: usedTokens,
contextWindowTokens: contextWindow,
totalCost: self.totalCost(in: messages))
}
/// Context pressure comes from the latest run: its usage already counts
/// the whole conversation the model saw, so runs are not summed.
private static func latestRunTokens(in messages: [OpenClawChatMessage]) -> Int? {
for message in messages.reversed() {
guard let usage = message.usage else { continue }
if let total = usage.total, total > 0 {
return total
}
let summed = [usage.input, usage.cacheRead, usage.cacheWrite, usage.output]
.compactMap(\.self)
.reduce(0, +)
if summed > 0 {
return summed
}
}
return nil
}
private static func totalCost(in messages: [OpenClawChatMessage]) -> Double? {
let costs = messages.compactMap { $0.usage?.cost?.total }
guard !costs.isEmpty else { return nil }
return costs.reduce(0, +)
}
private static func positive(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return value
}
}
extension OpenClawChatViewModel {
public var contextUsage: OpenClawChatContextUsage? {
let entry = self.sessions.first { $0.key == self.sessionKey } ??
self.sessions.first {
self.matchesCurrentSessionKey(incoming: $0.key, current: self.sessionKey)
}
return ChatContextUsageCalculator.usage(
messages: self.messages,
sessionEntry: entry,
defaults: self.sessionDefaults,
modelContextWindow: self.selectedModelContextWindow(sessionEntry: entry))
}
private func selectedModelContextWindow(sessionEntry: OpenClawChatSessionEntry?) -> Int? {
let selection = self.modelSelectionID != Self.defaultModelSelectionID
? self.modelSelectionID
: (sessionEntry?.model ?? self.sessionDefaults?.model)
guard let selection else { return nil }
return self.modelChoices.first {
$0.selectionID == selection || $0.modelID == selection
}?.contextWindow
}
}
struct ChatMessageUsagePresentation: Equatable {
enum Pressure: Equatable {
case normal
case warning
case danger
}
let text: String
let accessibilityValue: String
let pressure: Pressure
static func make(
message: OpenClawChatMessage,
contextWindowTokens: Int?) -> ChatMessageUsagePresentation?
{
guard message.role.lowercased() == "assistant", let usage = message.usage else { return nil }
var visualParts: [String] = []
var accessibilityParts: [String] = []
let input = self.positive(usage.input)
let output = self.positive(usage.output)
let cacheRead = self.positive(usage.cacheRead)
let cacheWrite = self.positive(usage.cacheWrite)
if let input {
visualParts.append("↑\(ChatCompactTokenCountFormatter.string(Double(input)))")
accessibilityParts.append(String(
format: String(localized: "Input tokens: %@"),
input.formatted()))
}
if let output {
visualParts.append("↓\(ChatCompactTokenCountFormatter.string(Double(output)))")
accessibilityParts.append(String(
format: String(localized: "Output tokens: %@"),
output.formatted()))
}
if let cacheRead {
visualParts.append("R\(ChatCompactTokenCountFormatter.string(Double(cacheRead)))")
accessibilityParts.append(String(
format: String(localized: "Cache read tokens: %@"),
cacheRead.formatted()))
}
if let cacheWrite {
visualParts.append("W\(ChatCompactTokenCountFormatter.string(Double(cacheWrite)))")
accessibilityParts.append(String(
format: String(localized: "Cache write tokens: %@"),
cacheWrite.formatted()))
}
if let cost = usage.cost?.total, cost > 0 {
let formattedCost = String(format: "$%.4f", locale: Locale(identifier: "en_US_POSIX"), cost)
visualParts.append(formattedCost)
accessibilityParts.append(String(
format: String(localized: "Cost: %@"),
formattedCost))
}
// Context pressure mirrors the Control UI prompt size. Output is response data;
// input plus cache reads/writes is the context the model received for this run.
let promptTokens = Double(input ?? 0) + Double(cacheRead ?? 0) + Double(cacheWrite ?? 0)
let contextPercent: Int?
if let contextWindowTokens, contextWindowTokens > 0, promptTokens > 0 {
let roundedPercent = (promptTokens / Double(contextWindowTokens) * 100).rounded()
contextPercent = Int(min(100, roundedPercent))
} else {
contextPercent = nil
}
let pressure = self.pressure(for: contextPercent)
if let contextPercent {
let warningPrefix = pressure == .normal ? "" : "⚠︎ "
visualParts.append("\(warningPrefix)\(contextPercent)% \(String(localized: "ctx"))")
switch pressure {
case .normal:
accessibilityParts.append(String(
format: String(localized: "%@ percent of context used"),
contextPercent.formatted()))
case .warning:
accessibilityParts.append(String(
format: String(localized: "Warning: %@ percent of context used"),
contextPercent.formatted()))
case .danger:
accessibilityParts.append(String(
format: String(localized: "Critical: %@ percent of context used"),
contextPercent.formatted()))
}
}
guard !visualParts.isEmpty else { return nil }
return ChatMessageUsagePresentation(
text: visualParts.joined(separator: " "),
accessibilityValue: accessibilityParts.joined(separator: ", "),
pressure: pressure)
}
private static func pressure(for percent: Int?) -> Pressure {
guard let percent else { return .normal }
if percent >= 90 { return .danger }
if percent >= 75 { return .warning }
return .normal
}
private static func positive(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return value
}
}
enum ChatContextUsageFormatter {
static func tokens(_ value: Int) -> String {
if value >= 1_000_000 {
return String(format: "%.1fM", Double(value) / 1_000_000)
}
if value >= 1000 {
return String(format: "%.1fk", Double(value) / 1000)
}
return "\(value)"
}
static func cost(_ value: Double) -> String {
String(format: "$%.2f", value)
}
}
|