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
22 changes: 15 additions & 7 deletions Sources/TokiUsageCore/ActivityTimeEstimator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ public struct ActivityTimeEstimate<Key: Hashable> {
public let wallClockSeconds: TimeInterval
public let activeStreamCount: Int
public let maxConcurrentStreams: Int
/// Agent work time per key. Concurrent streams are summed, so this can exceed the
/// elapsed time.
public let secondsByKey: [Key: TimeInterval]
/// Elapsed time per key. Concurrent streams sharing a key are merged, so this never
/// exceeds the range the key was observed in.
public let wallClockSecondsByKey: [Key: TimeInterval]

public static var zero: Self {
ActivityTimeEstimate(
Expand All @@ -40,7 +45,8 @@ public struct ActivityTimeEstimate<Key: Hashable> {
wallClockSeconds: 0,
activeStreamCount: 0,
maxConcurrentStreams: 0,
secondsByKey: [:])
secondsByKey: [:],
wallClockSecondsByKey: [:])
}
}

Expand Down Expand Up @@ -78,15 +84,16 @@ public enum ActivityTimeEstimator {
let wallClockSeconds = mergedDuration(mergedStreamIntervals)
let activeStreamCount = Set(intervals.map(\.streamID)).count
let maxConcurrentStreams = maximumConcurrentStreams(mergedStreamIntervals)
let secondsByKey = Dictionary(
let intervalsByKey = Dictionary(
grouping: intervals.compactMap { interval -> (Key, ActivityInterval<Key>)? in
guard let key = interval.key else { return nil }
return (key, interval)
},
by: \.0).reduce(into: [Key: TimeInterval]()) { result, item in
let (key, intervalsForKey) = item
result[key] = summedDurationByStream(intervalsForKey.map(\.1))
}
by: \.0).mapValues { $0.map(\.1) }
let secondsByKey = intervalsByKey.mapValues(summedDurationByStream)
let wallClockSecondsByKey = intervalsByKey.mapValues { intervalsForKey in
mergedDuration(intervalsForKey.map { DateInterval(start: $0.start, end: $0.end) })
}

return ActivityTimeEstimate(
totalSeconds: totalSeconds,
Expand All @@ -95,7 +102,8 @@ public enum ActivityTimeEstimator {
wallClockSeconds: wallClockSeconds,
activeStreamCount: activeStreamCount,
maxConcurrentStreams: maxConcurrentStreams,
secondsByKey: secondsByKey)
secondsByKey: secondsByKey,
wallClockSecondsByKey: wallClockSecondsByKey)
}

private static func estimatedSlice(
Expand Down
11 changes: 11 additions & 0 deletions Sources/TokiUsageCore/RawTokenUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,23 @@ public struct SupplementalUsage {
public struct PerModelUsage {
public var totalTokens: Int
public var cost: Double
/// Agent work time. Concurrent streams are summed, so this can exceed the elapsed
/// time when several sessions ran in parallel.
public var activeSeconds: TimeInterval
/// Elapsed time the model was in use, merging concurrent streams.
public var wallClockSeconds: TimeInterval
public var sources: Set<String>

public init(
totalTokens: Int = 0,
cost: Double = 0,
activeSeconds: TimeInterval = 0,
wallClockSeconds: TimeInterval = 0,
sources: Set<String> = []) {
self.totalTokens = totalTokens
self.cost = cost
self.activeSeconds = activeSeconds
self.wallClockSeconds = wallClockSeconds
self.sources = sources
}
}
Expand Down Expand Up @@ -401,17 +407,22 @@ public func += (lhs: inout RawTokenUsage, rhs: RawTokenUsage) {
lhs.fallbackActiveSeconds += rhs.activeSeconds
}

// Wall-clock time is summed here only so readers that never expose activity events
// still report something. When events exist, recomputeMergedActiveEstimate()
// replaces both values with one merged estimate over every event.
for (id, usage) in rhs.perModel {
lhs.perModel[id, default: PerModelUsage()].totalTokens += usage.totalTokens
lhs.perModel[id, default: PerModelUsage()].cost += usage.cost
lhs.perModel[id, default: PerModelUsage()].activeSeconds += usage.activeSeconds
lhs.perModel[id, default: PerModelUsage()].wallClockSeconds += usage.wallClockSeconds
lhs.perModel[id, default: PerModelUsage()].sources.formUnion(usage.sources)
}

for (key, usage) in rhs.perModelBySource {
lhs.perModelBySource[key, default: PerModelUsage()].totalTokens += usage.totalTokens
lhs.perModelBySource[key, default: PerModelUsage()].cost += usage.cost
lhs.perModelBySource[key, default: PerModelUsage()].activeSeconds += usage.activeSeconds
lhs.perModelBySource[key, default: PerModelUsage()].wallClockSeconds += usage.wallClockSeconds
lhs.perModelBySource[key, default: PerModelUsage()].sources.formUnion(usage.sources)
}

Expand Down
28 changes: 28 additions & 0 deletions Sources/TokiUsageReaders/ReaderSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public extension RawTokenUsage {
perModel[modelID, default: PerModelUsage()].activeSeconds += seconds
perModel[modelID, default: PerModelUsage()].sources.insert(source)
}
for (modelID, seconds) in estimate.wallClockSecondsByKey {
perModel[modelID, default: PerModelUsage()].wallClockSeconds += seconds
}
}

mutating func mergeActivityEvents(
Expand All @@ -75,6 +78,12 @@ public extension RawTokenUsage {
source: String? = nil,
clippingEndDate: Date? = nil) {
guard !activityEvents.isEmpty else {
// Readers that report totals without timestamps never reach the estimate
// below, so carry their duration into the wall-clock field here. Leaving it
// at zero would export an unmeasured zero for time that was measured.
for modelID in perModel.keys {
perModel[modelID]?.wallClockSeconds = fallbackActiveSecondsByModel[modelID, default: 0]
}
let fallbackOnlyWorkTime = resolvedFallbackWorkTime
fallbackWorkTime = fallbackOnlyWorkTime
workTime = fallbackOnlyWorkTime
Expand All @@ -84,6 +93,7 @@ public extension RawTokenUsage {
activeSeconds = fallbackActiveSeconds
for modelID in perModel.keys {
perModel[modelID]?.activeSeconds = fallbackActiveSecondsByModel[modelID, default: 0]
perModel[modelID]?.wallClockSeconds = fallbackActiveSecondsByModel[modelID, default: 0]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

let estimate = ActivityTimeEstimator.estimate(
Expand All @@ -107,6 +117,24 @@ public extension RawTokenUsage {
perModel[modelID, default: PerModelUsage()].sources.insert(source)
}
}
for (modelID, seconds) in estimate.wallClockSecondsByKey {
perModel[modelID, default: PerModelUsage()].wallClockSeconds += seconds
}
boundModelSourceWallClockToModelTotals()
}

/// `perModelBySource` is summed per origin, so a model observed on several origins
/// would otherwise report their durations added together. Bound each row by the
/// model's merged span so no row claims more elapsed time than the model was in use.
private mutating func boundModelSourceWallClockToModelTotals() {
for (key, usage) in perModelBySource {
guard let modelWallClock = perModel[key.modelID]?.wallClockSeconds,
modelWallClock > 0,
usage.wallClockSeconds > modelWallClock else {
continue
}
perModelBySource[key]?.wallClockSeconds = modelWallClock
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions Toki.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
187A94A7422CB21663B9214F /* CodexReaderBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 744538423BE5FD3B64B2E8A0 /* CodexReaderBehaviorTests.swift */; };
19158307E719CB6441DD8841 /* SecurityAuditSQLiteScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEBBC19D10BB66820B603F3 /* SecurityAuditSQLiteScanner.swift */; };
19402E1978419587580E9C87 /* UsageAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 107B89D8C6829F00817A902B /* UsageAggregator.swift */; };
1C281EBD8C94C6F48C80798F /* UsageModelSourceMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6E92AC47DF57B49E5EF33C /* UsageModelSourceMerge.swift */; };
1CB6457496EDB42014D2577C /* PanelHourlyUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F4D14E84D7569820019419 /* PanelHourlyUsageView.swift */; };
1CEB7251F4D5C4E54A0374BC /* ProjectTimelineBreakdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A99E233235E7ED7777321F8 /* ProjectTimelineBreakdown.swift */; };
2427CB79A2FA6D1E09CC5917 /* UsagePanelRefreshCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFAAE90B6B20174754C0B0E /* UsagePanelRefreshCoordinator.swift */; };
Expand Down Expand Up @@ -273,6 +274,7 @@
A3BCD6B222FAE20E8AD17A13 /* SecurityAuditScannerTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityAuditScannerTestSupport.swift; sourceTree = "<group>"; };
A4692734D41D5DC621C0BF7F /* CodexReaderTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexReaderTestSupport.swift; sourceTree = "<group>"; };
A935D97F072BD30BEA608E73 /* TokiTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TokiTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AA6E92AC47DF57B49E5EF33C /* UsageModelSourceMerge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageModelSourceMerge.swift; sourceTree = "<group>"; };
AFF0A0D8A54BCA7B3B1D5403 /* LocalUsageReaderRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalUsageReaderRegistryTests.swift; sourceTree = "<group>"; };
B03817C34A0165DBDAA6B1C1 /* RemoteHubClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteHubClient.swift; sourceTree = "<group>"; };
B1081585613F3D38A303C7B0 /* RemoteUsageReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteUsageReader.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -410,6 +412,7 @@
D43001DCEB9A2BC563AAC217 /* ReaderFetchResult.swift */,
8B5FAD8A81645F8C02AC6D46 /* RemotePricingCatalog.swift */,
107B89D8C6829F00817A902B /* UsageAggregator.swift */,
AA6E92AC47DF57B49E5EF33C /* UsageModelSourceMerge.swift */,
);
path = UsageReaders;
sourceTree = "<group>";
Expand Down Expand Up @@ -830,6 +833,7 @@
19402E1978419587580E9C87 /* UsageAggregator.swift in Sources */,
F5AE38AB9B9F16524BAB4B6A /* UsageData.swift in Sources */,
107B19A86A1C04A01E4BE104 /* UsageFormatting.swift in Sources */,
1C281EBD8C94C6F48C80798F /* UsageModelSourceMerge.swift in Sources */,
2427CB79A2FA6D1E09CC5917 /* UsagePanelRefreshCoordinator.swift in Sources */,
1329E9A5945F16ABA700B7B1 /* UsagePanelSettings.swift in Sources */,
E5F6B418F6066E72F501BA39 /* UsagePanelView.swift in Sources */,
Expand Down
52 changes: 52 additions & 0 deletions Toki/Domain/Usage/UsageData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ struct ModelStat: Equatable {
let totalTokens: Int
let cost: Double
let activeSeconds: TimeInterval
let wallClockSeconds: TimeInterval
let sources: [String]
let isPriceKnown: Bool

Expand All @@ -77,13 +78,15 @@ struct ModelStat: Equatable {
totalTokens: Int,
cost: Double,
activeSeconds: TimeInterval,
wallClockSeconds: TimeInterval = 0,
sources: [String],
isPriceKnown: Bool) {
self.id = id
self.modelID = modelID ?? id
self.totalTokens = totalTokens
self.cost = cost
self.activeSeconds = activeSeconds
self.wallClockSeconds = wallClockSeconds
self.sources = sources
self.isPriceKnown = isPriceKnown
}
Expand All @@ -93,6 +96,16 @@ struct ModelStat: Equatable {
? UsageModelGrouping.mixedOrUnattributedLabel
: modelID
}

var parallelMultiplier: Double {
usageParallelMultiplier(agentSeconds: activeSeconds, wallClockSeconds: wallClockSeconds)
}

/// Elapsed time when it was measured, otherwise the summed agent time so readers
/// without activity events keep reporting a duration.
var reportedSeconds: TimeInterval {
wallClockSeconds > 0 ? wallClockSeconds : activeSeconds
}
}

struct SourceStat: Equatable {
Expand All @@ -104,6 +117,28 @@ struct SourceStat: Equatable {
let reasoningTokens: Int
let cost: Double
let activeSeconds: TimeInterval
let wallClockSeconds: TimeInterval

init(
source: String,
inputTokens: Int,
outputTokens: Int,
cacheReadTokens: Int,
cacheWriteTokens: Int,
reasoningTokens: Int,
cost: Double,
activeSeconds: TimeInterval,
wallClockSeconds: TimeInterval = 0) {
self.source = source
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheReadTokens = cacheReadTokens
self.cacheWriteTokens = cacheWriteTokens
self.reasoningTokens = reasoningTokens
self.cost = cost
self.activeSeconds = activeSeconds
self.wallClockSeconds = wallClockSeconds
}

var id: String {
source
Expand All @@ -112,6 +147,23 @@ struct SourceStat: Equatable {
var totalTokens: Int {
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens
}

var parallelMultiplier: Double {
usageParallelMultiplier(agentSeconds: activeSeconds, wallClockSeconds: wallClockSeconds)
}

var reportedSeconds: TimeInterval {
wallClockSeconds > 0 ? wallClockSeconds : activeSeconds
}
}

/// Mirrors `WorkTimeMetrics.parallelMultiplier` so panel rows and the overview agree on
/// how parallelism is expressed.
func usageParallelMultiplier(
agentSeconds: TimeInterval,
wallClockSeconds: TimeInterval) -> Double {
guard wallClockSeconds > 0 else { return 0 }
return agentSeconds / wallClockSeconds
}

struct UsageTimeBucket: Identifiable, Equatable {
Expand Down
16 changes: 16 additions & 0 deletions Toki/Domain/Usage/UsageFormatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,19 @@ extension TimeInterval {
return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
}
}

/// Smallest multiplier worth showing. Below this the row read as plain elapsed time.
private let minimumReportedParallelMultiplier = 1.2

/// Renders "3h 0m used · x3.9 parallel" so a row states elapsed time first and keeps the
/// summed agent time visible as a ratio instead of as an impossible duration.
func formattedUsageTimeSummary(
reportedSeconds: TimeInterval,
parallelMultiplier: Double) -> String {
let duration = "\(reportedSeconds.formattedWorkDuration()) used"
guard parallelMultiplier.isFinite,
parallelMultiplier >= minimumReportedParallelMultiplier else {
return duration
}
return "\(duration) · x\(String(format: "%.1f", parallelMultiplier)) parallel"
}
Loading
Loading