diff --git a/Sources/TokiUsageCore/ActivityTimeEstimator.swift b/Sources/TokiUsageCore/ActivityTimeEstimator.swift index b7664cc..1ac5253 100644 --- a/Sources/TokiUsageCore/ActivityTimeEstimator.swift +++ b/Sources/TokiUsageCore/ActivityTimeEstimator.swift @@ -30,7 +30,12 @@ public struct ActivityTimeEstimate { 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( @@ -40,7 +45,8 @@ public struct ActivityTimeEstimate { wallClockSeconds: 0, activeStreamCount: 0, maxConcurrentStreams: 0, - secondsByKey: [:]) + secondsByKey: [:], + wallClockSecondsByKey: [:]) } } @@ -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)? 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, @@ -95,7 +102,8 @@ public enum ActivityTimeEstimator { wallClockSeconds: wallClockSeconds, activeStreamCount: activeStreamCount, maxConcurrentStreams: maxConcurrentStreams, - secondsByKey: secondsByKey) + secondsByKey: secondsByKey, + wallClockSecondsByKey: wallClockSecondsByKey) } private static func estimatedSlice( diff --git a/Sources/TokiUsageCore/RawTokenUsage.swift b/Sources/TokiUsageCore/RawTokenUsage.swift index c1e1dfd..f3566bc 100644 --- a/Sources/TokiUsageCore/RawTokenUsage.swift +++ b/Sources/TokiUsageCore/RawTokenUsage.swift @@ -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 public init( totalTokens: Int = 0, cost: Double = 0, activeSeconds: TimeInterval = 0, + wallClockSeconds: TimeInterval = 0, sources: Set = []) { self.totalTokens = totalTokens self.cost = cost self.activeSeconds = activeSeconds + self.wallClockSeconds = wallClockSeconds self.sources = sources } } @@ -401,10 +407,14 @@ 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) } @@ -412,6 +422,7 @@ public func += (lhs: inout RawTokenUsage, rhs: RawTokenUsage) { 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) } diff --git a/Sources/TokiUsageReaders/ReaderSupport.swift b/Sources/TokiUsageReaders/ReaderSupport.swift index 1faaa6f..51d6b7d 100644 --- a/Sources/TokiUsageReaders/ReaderSupport.swift +++ b/Sources/TokiUsageReaders/ReaderSupport.swift @@ -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( @@ -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 @@ -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] } let estimate = ActivityTimeEstimator.estimate( @@ -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 + } } } diff --git a/Toki.xcodeproj/project.pbxproj b/Toki.xcodeproj/project.pbxproj index dbdb2b0..597714e 100644 --- a/Toki.xcodeproj/project.pbxproj +++ b/Toki.xcodeproj/project.pbxproj @@ -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 */; }; @@ -273,6 +274,7 @@ A3BCD6B222FAE20E8AD17A13 /* SecurityAuditScannerTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityAuditScannerTestSupport.swift; sourceTree = ""; }; A4692734D41D5DC621C0BF7F /* CodexReaderTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexReaderTestSupport.swift; sourceTree = ""; }; 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 = ""; }; AFF0A0D8A54BCA7B3B1D5403 /* LocalUsageReaderRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalUsageReaderRegistryTests.swift; sourceTree = ""; }; B03817C34A0165DBDAA6B1C1 /* RemoteHubClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteHubClient.swift; sourceTree = ""; }; B1081585613F3D38A303C7B0 /* RemoteUsageReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteUsageReader.swift; sourceTree = ""; }; @@ -410,6 +412,7 @@ D43001DCEB9A2BC563AAC217 /* ReaderFetchResult.swift */, 8B5FAD8A81645F8C02AC6D46 /* RemotePricingCatalog.swift */, 107B89D8C6829F00817A902B /* UsageAggregator.swift */, + AA6E92AC47DF57B49E5EF33C /* UsageModelSourceMerge.swift */, ); path = UsageReaders; sourceTree = ""; @@ -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 */, diff --git a/Toki/Domain/Usage/UsageData.swift b/Toki/Domain/Usage/UsageData.swift index bcc8468..4451b3f 100644 --- a/Toki/Domain/Usage/UsageData.swift +++ b/Toki/Domain/Usage/UsageData.swift @@ -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 @@ -77,6 +78,7 @@ struct ModelStat: Equatable { totalTokens: Int, cost: Double, activeSeconds: TimeInterval, + wallClockSeconds: TimeInterval = 0, sources: [String], isPriceKnown: Bool) { self.id = id @@ -84,6 +86,7 @@ struct ModelStat: Equatable { self.totalTokens = totalTokens self.cost = cost self.activeSeconds = activeSeconds + self.wallClockSeconds = wallClockSeconds self.sources = sources self.isPriceKnown = isPriceKnown } @@ -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 { @@ -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 @@ -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 { diff --git a/Toki/Domain/Usage/UsageFormatting.swift b/Toki/Domain/Usage/UsageFormatting.swift index a722124..a2e3170 100644 --- a/Toki/Domain/Usage/UsageFormatting.swift +++ b/Toki/Domain/Usage/UsageFormatting.swift @@ -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" +} diff --git a/Toki/Domain/Usage/UsageReportExport.swift b/Toki/Domain/Usage/UsageReportExport.swift index 93544d4..e75cb3e 100644 --- a/Toki/Domain/Usage/UsageReportExport.swift +++ b/Toki/Domain/Usage/UsageReportExport.swift @@ -69,6 +69,7 @@ private struct UsageExportTotals: Encodable { let totalTokens: Int let cost: Double let activeSeconds: TimeInterval + let wallClockSeconds: TimeInterval init(usage: UsageData) { inputTokens = usage.inputTokens @@ -79,6 +80,7 @@ private struct UsageExportTotals: Encodable { totalTokens = usage.totalTokens cost = usage.cost activeSeconds = usage.activeSeconds + wallClockSeconds = usage.workTime.wallClockSeconds } } @@ -92,6 +94,7 @@ private struct UsageExportSource: Encodable { let totalTokens: Int let cost: Double let activeSeconds: TimeInterval + let wallClockSeconds: TimeInterval init(source: SourceStat) { self.source = source.source @@ -103,6 +106,7 @@ private struct UsageExportSource: Encodable { totalTokens = source.totalTokens cost = source.cost activeSeconds = source.activeSeconds + wallClockSeconds = source.wallClockSeconds } } @@ -111,6 +115,7 @@ private struct UsageExportModel: Encodable { let totalTokens: Int let cost: Double let activeSeconds: TimeInterval + let wallClockSeconds: TimeInterval let sources: [String] let isPriceKnown: Bool @@ -119,6 +124,7 @@ private struct UsageExportModel: Encodable { totalTokens = model.totalTokens cost = model.cost activeSeconds = model.activeSeconds + wallClockSeconds = model.wallClockSeconds sources = model.sources isPriceKnown = model.isPriceKnown } @@ -201,7 +207,9 @@ private extension UsageExport { "reasoning_tokens", "total_tokens", "cost_usd", + // active_seconds sums concurrent streams; wall_clock_seconds merges them. "active_seconds", + "wall_clock_seconds", "start_date", "end_date", "project_path", @@ -227,6 +235,7 @@ private extension UsageExport { "\(usage.totalTokens)", String(format: "%.6f", usage.cost), String(format: "%.3f", usage.activeSeconds), + String(format: "%.3f", usage.workTime.wallClockSeconds), startDate, endDate, "", @@ -253,6 +262,7 @@ private extension UsageExport { "\(source.totalTokens)", String(format: "%.6f", source.cost), String(format: "%.3f", source.activeSeconds), + String(format: "%.3f", source.wallClockSeconds), startDate, endDate, "", @@ -280,6 +290,7 @@ private extension UsageExport { "\(model.totalTokens)", model.isPriceKnown ? String(format: "%.6f", model.cost) : "", String(format: "%.3f", model.activeSeconds), + String(format: "%.3f", model.wallClockSeconds), startDate, endDate, "", @@ -304,6 +315,7 @@ private extension UsageExport { "\(project.totalTokens)", String(format: "%.6f", project.cost), "", + "", project.firstActivityAt.map { usageExportISODateFormatter.string(from: $0) } ?? "", project.lastActivityAt.map { usageExportISODateFormatter.string(from: $0) } ?? "", project.path ?? "", @@ -328,6 +340,7 @@ private extension UsageExport { "\(session.totalTokens)", String(format: "%.6f", session.cost), "", + "", usageExportISODateFormatter.string(from: session.firstActivityAt), usageExportISODateFormatter.string(from: session.lastActivityAt), session.projectPath ?? "", diff --git a/Toki/Domain/Usage/UsageReportModelStats.swift b/Toki/Domain/Usage/UsageReportModelStats.swift index 8f9b256..88daf7f 100644 --- a/Toki/Domain/Usage/UsageReportModelStats.swift +++ b/Toki/Domain/Usage/UsageReportModelStats.swift @@ -6,6 +6,7 @@ private struct ModelSourceStatAggregate { var totalTokens = 0 var cost: Double = 0 var activeSeconds: TimeInterval = 0 + var wallClockSeconds: TimeInterval = 0 var sources = Set() var isPriceKnown = true @@ -15,6 +16,7 @@ private struct ModelSourceStatAggregate { totalTokens = usage.totalTokens cost = usage.cost activeSeconds = usage.activeSeconds + wallClockSeconds = usage.wallClockSeconds sources = Set(usage.sources.compactMap(\.trimmedNonEmpty)) if sources.isEmpty { sources.insert(source) @@ -32,7 +34,7 @@ private struct ModelSourceStatAggregate { } var hasReportableData: Bool { - totalTokens > 0 || cost > 0 || activeSeconds > 0 + totalTokens > 0 || cost > 0 || activeSeconds > 0 || wallClockSeconds > 0 } } @@ -82,6 +84,7 @@ extension UsageReportBuilder { totalTokens: aggregate.totalTokens, cost: aggregate.cost, activeSeconds: aggregate.activeSeconds, + wallClockSeconds: aggregate.wallClockSeconds, sources: sources, isPriceKnown: priceIsKnown( for: key, @@ -151,6 +154,10 @@ extension UsageReportBuilder { let key = ModelSourceUsageKey(modelID: modelID, source: source) aggregates[key]?.activeSeconds = activeSeconds } + for (modelID, wallClockSeconds) in estimate.wallClockSecondsByKey { + let key = ModelSourceUsageKey(modelID: modelID, source: source) + aggregates[key]?.wallClockSeconds = wallClockSeconds + } } return aggregates @@ -169,13 +176,20 @@ extension UsageReportBuilder { let coveredTokens = matchingStats.values.reduce(0) { $0 + $1.totalTokens } let coveredCost = matchingStats.values.reduce(0) { $0 + $1.cost } let coveredActiveSeconds = matchingStats.values.reduce(0) { $0 + $1.activeSeconds } + let coveredWallClockSeconds = matchingStats.values.reduce(0) { $0 + $1.wallClockSeconds } let residualTokens = max(0, legacyStat.totalTokens - coveredTokens) let residualCost = positiveDifference(legacyStat.cost, coveredCost) let residualActiveSeconds = positiveDifference( legacyStat.activeSeconds, coveredActiveSeconds) + let residualWallClockSeconds = positiveDifference( + legacyStat.wallClockSeconds, + coveredWallClockSeconds) - guard residualTokens > 0 || residualCost > 0 || residualActiveSeconds > 0 else { + guard residualTokens > 0 + || residualCost > 0 + || residualActiveSeconds > 0 + || residualWallClockSeconds > 0 else { continue } @@ -192,6 +206,8 @@ extension UsageReportBuilder { aggregates[key, default: ModelSourceStatAggregate()].totalTokens += residualTokens aggregates[key, default: ModelSourceStatAggregate()].cost += residualCost aggregates[key, default: ModelSourceStatAggregate()].activeSeconds += residualActiveSeconds + aggregates[key, default: ModelSourceStatAggregate()] + .wallClockSeconds += residualWallClockSeconds aggregates[key, default: ModelSourceStatAggregate()].sources.formUnion(residualSources) } } diff --git a/Toki/Features/UsagePanel/PanelSourceExportViews.swift b/Toki/Features/UsagePanel/PanelSourceExportViews.swift index a57b2c3..d0b3112 100644 --- a/Toki/Features/UsagePanel/PanelSourceExportViews.swift +++ b/Toki/Features/UsagePanel/PanelSourceExportViews.swift @@ -238,7 +238,12 @@ private struct SourceStatRowView: View, Equatable { .font(.system(size: 11)) .foregroundColor(Color.white.opacity(0.48)) .lineLimit(1) - Text(stat.activeSeconds > 0 ? "\(stat.activeSeconds.formattedWorkDuration()) used" : "0s used") + Text( + stat.reportedSeconds > 0 + ? formattedUsageTimeSummary( + reportedSeconds: stat.reportedSeconds, + parallelMultiplier: stat.parallelMultiplier) + : "0s used") .font(.system(size: 10, weight: .medium)) .foregroundColor(Color.white.opacity(0.3)) .lineLimit(1) diff --git a/Toki/Features/UsagePanel/PanelStatComponents.swift b/Toki/Features/UsagePanel/PanelStatComponents.swift index efd9896..d0631e2 100644 --- a/Toki/Features/UsagePanel/PanelStatComponents.swift +++ b/Toki/Features/UsagePanel/PanelStatComponents.swift @@ -92,8 +92,10 @@ struct ModelStatRowView: View, Equatable { extension ModelStat { var panelTimeSummary: String { - if activeSeconds > 0 { - return "\(activeSeconds.formattedWorkDuration()) used" + if reportedSeconds > 0 { + return formattedUsageTimeSummary( + reportedSeconds: reportedSeconds, + parallelMultiplier: parallelMultiplier) } return cost > 0 ? "cost only" : "0s used" } diff --git a/Toki/Infrastructure/RemoteSync/RemoteUsageMapper.swift b/Toki/Infrastructure/RemoteSync/RemoteUsageMapper.swift index 5aa016f..cafd96e 100644 --- a/Toki/Infrastructure/RemoteSync/RemoteUsageMapper.swift +++ b/Toki/Infrastructure/RemoteSync/RemoteUsageMapper.swift @@ -72,7 +72,8 @@ struct RemoteUsageMapper { cacheWriteTokens: usage.cacheWriteTokens, reasoningTokens: usage.reasoningTokens, cost: usage.cost, - activeSeconds: usage.activeSeconds) + activeSeconds: usage.activeSeconds, + wallClockSeconds: usage.resolvedWorkTime.wallClockSeconds) } .sorted { lhs, rhs in if lhs.totalTokens != rhs.totalTokens { return lhs.totalTokens > rhs.totalTokens } diff --git a/Toki/Infrastructure/UsageReaders/UsageAggregator.swift b/Toki/Infrastructure/UsageReaders/UsageAggregator.swift index 384a180..2f79a27 100644 --- a/Toki/Infrastructure/UsageReaders/UsageAggregator.swift +++ b/Toki/Infrastructure/UsageReaders/UsageAggregator.swift @@ -349,6 +349,7 @@ private struct SourceStatAggregate { var reasoningTokens = 0 var cost: Double = 0 var activeSeconds: TimeInterval = 0 + var wallClockSeconds: TimeInterval = 0 mutating func merge(_ stat: SourceStat) { inputTokens += stat.inputTokens @@ -358,6 +359,7 @@ private struct SourceStatAggregate { reasoningTokens += stat.reasoningTokens cost += stat.cost activeSeconds += stat.activeSeconds + wallClockSeconds += stat.wallClockSeconds } var sourceStat: SourceStat { @@ -369,7 +371,8 @@ private struct SourceStatAggregate { cacheWriteTokens: cacheWriteTokens, reasoningTokens: reasoningTokens, cost: cost, - activeSeconds: activeSeconds) + activeSeconds: activeSeconds, + wallClockSeconds: wallClockSeconds) } } @@ -432,7 +435,7 @@ private func mergedSourceStats(_ sourceStats: [SourceStat]) -> [SourceStat] { return aggregates.values.map(\.sourceStat).sorted(by: sourceStatSort) } -private func fallbackSource(for slice: UsageOriginSlice) -> String { +func fallbackSource(for slice: UsageOriginSlice) -> String { let sources = Set(slice.sourceStats.compactMap(\.source.trimmedNonEmpty)) if sources.count == 1, let source = sources.first { return source @@ -473,113 +476,8 @@ private func sourceStat(from usage: RawTokenUsage, source: String, includeEmpty: cacheWriteTokens: usage.cacheWriteTokens, reasoningTokens: usage.reasoningTokens, cost: usage.cost, - activeSeconds: usage.activeSeconds) -} - -private func mergedModelSourceUsage( - from slices: [UsageOriginSlice], - startDate: Date, - endDate: Date) -> [ModelSourceUsageKey: PerModelUsage] { - var result: [ModelSourceUsageKey: PerModelUsage] = [:] - - for slice in slices { - if slice.origin.kind == .remote { - mergeRemoteModelUsage( - slice.usage, - source: fallbackSource(for: slice), - startDate: startDate, - endDate: endDate, - into: &result) - continue - } - - var explicitlyMappedModels = Set() - for (key, usage) in slice.usage.perModelBySource { - guard let modelID = key.modelID.trimmedNonEmpty, - let source = key.source.trimmedNonEmpty else { - continue - } - explicitlyMappedModels.insert(modelID) - accumulateModelUsage( - usage, - key: ModelSourceUsageKey(modelID: modelID, source: source), - into: &result) - } - mergeLegacyPerModelUsage( - slice.usage.perModel, - source: fallbackSource(for: slice), - excluding: explicitlyMappedModels, - into: &result) - } - return result -} - -private func mergeRemoteModelUsage( - _ usage: RawTokenUsage, - source fallbackSource: String, - startDate: Date, - endDate: Date, - into result: inout [ModelSourceUsageKey: PerModelUsage]) { - guard let fallbackSource = fallbackSource.trimmedNonEmpty else { return } - - for model in UsageReportBuilder.buildModelStats( - from: usage, - startDate: startDate, - endDate: endDate) { - guard let modelID = model.modelID.trimmedNonEmpty else { continue } - let sources = Set(model.sources.compactMap(\.trimmedNonEmpty)) - let source = sources.count == 1 - ? sources.first ?? fallbackSource - : fallbackSource - accumulateModelUsage( - PerModelUsage( - totalTokens: model.totalTokens, - cost: model.cost, - activeSeconds: model.activeSeconds, - sources: sources), - key: ModelSourceUsageKey(modelID: modelID, source: source), - into: &result) - } -} - -private func mergeLegacyPerModelUsage( - _ modelUsage: [String: PerModelUsage], - source fallbackSource: String, - excluding sourceMappedModels: Set, - into result: inout [ModelSourceUsageKey: PerModelUsage]) { - guard let fallbackSource = fallbackSource.trimmedNonEmpty else { return } - - for (rawModelID, usage) in modelUsage { - guard let modelID = rawModelID.trimmedNonEmpty, - !sourceMappedModels.contains(modelID) else { - continue - } - let usageSources = Set(usage.sources.compactMap(\.trimmedNonEmpty)) - let source = usageSources.count == 1 - ? usageSources.first ?? fallbackSource - : fallbackSource - accumulateModelUsage( - usage, - key: ModelSourceUsageKey(modelID: modelID, source: source), - into: &result) - } -} - -private func accumulateModelUsage( - _ usage: PerModelUsage, - key: ModelSourceUsageKey, - into result: inout [ModelSourceUsageKey: PerModelUsage]) { - var entry = result[key] ?? PerModelUsage() - entry.totalTokens += usage.totalTokens - entry.cost += usage.cost - entry.activeSeconds += usage.activeSeconds - let usageSources = Set(usage.sources.compactMap(\.trimmedNonEmpty)) - if usageSources.isEmpty { - entry.sources.insert(key.source) - } else { - entry.sources.formUnion(usageSources) - } - result[key] = entry + activeSeconds: usage.activeSeconds, + wallClockSeconds: usage.resolvedWorkTime.wallClockSeconds) } private func sourceStatSort(_ lhs: SourceStat, _ rhs: SourceStat) -> Bool { diff --git a/Toki/Infrastructure/UsageReaders/UsageModelSourceMerge.swift b/Toki/Infrastructure/UsageReaders/UsageModelSourceMerge.swift new file mode 100644 index 0000000..1ec5199 --- /dev/null +++ b/Toki/Infrastructure/UsageReaders/UsageModelSourceMerge.swift @@ -0,0 +1,113 @@ +import Foundation +import TokiUsageCore + +/// Maps each origin's model usage onto `(model, source)` keys so the panel can show one +/// row per model and source. Remote origins are routed through the report builder first +/// because their source labels carry the device name. +func mergedModelSourceUsage( + from slices: [UsageOriginSlice], + startDate: Date, + endDate: Date) -> [ModelSourceUsageKey: PerModelUsage] { + var result: [ModelSourceUsageKey: PerModelUsage] = [:] + + for slice in slices { + if slice.origin.kind == .remote { + mergeRemoteModelUsage( + slice.usage, + source: fallbackSource(for: slice), + startDate: startDate, + endDate: endDate, + into: &result) + continue + } + + var explicitlyMappedModels = Set() + for (key, usage) in slice.usage.perModelBySource { + guard let modelID = key.modelID.trimmedNonEmpty, + let source = key.source.trimmedNonEmpty else { + continue + } + explicitlyMappedModels.insert(modelID) + accumulateModelUsage( + usage, + key: ModelSourceUsageKey(modelID: modelID, source: source), + into: &result) + } + mergeLegacyPerModelUsage( + slice.usage.perModel, + source: fallbackSource(for: slice), + excluding: explicitlyMappedModels, + into: &result) + } + return result +} + +private func mergeRemoteModelUsage( + _ usage: RawTokenUsage, + source fallbackSource: String, + startDate: Date, + endDate: Date, + into result: inout [ModelSourceUsageKey: PerModelUsage]) { + guard let fallbackSource = fallbackSource.trimmedNonEmpty else { return } + + for model in UsageReportBuilder.buildModelStats( + from: usage, + startDate: startDate, + endDate: endDate) { + guard let modelID = model.modelID.trimmedNonEmpty else { continue } + let sources = Set(model.sources.compactMap(\.trimmedNonEmpty)) + let source = sources.count == 1 + ? sources.first ?? fallbackSource + : fallbackSource + accumulateModelUsage( + PerModelUsage( + totalTokens: model.totalTokens, + cost: model.cost, + activeSeconds: model.activeSeconds, + wallClockSeconds: model.wallClockSeconds, + sources: sources), + key: ModelSourceUsageKey(modelID: modelID, source: source), + into: &result) + } +} + +private func mergeLegacyPerModelUsage( + _ modelUsage: [String: PerModelUsage], + source fallbackSource: String, + excluding sourceMappedModels: Set, + into result: inout [ModelSourceUsageKey: PerModelUsage]) { + guard let fallbackSource = fallbackSource.trimmedNonEmpty else { return } + + for (rawModelID, usage) in modelUsage { + guard let modelID = rawModelID.trimmedNonEmpty, + !sourceMappedModels.contains(modelID) else { + continue + } + let usageSources = Set(usage.sources.compactMap(\.trimmedNonEmpty)) + let source = usageSources.count == 1 + ? usageSources.first ?? fallbackSource + : fallbackSource + accumulateModelUsage( + usage, + key: ModelSourceUsageKey(modelID: modelID, source: source), + into: &result) + } +} + +private func accumulateModelUsage( + _ usage: PerModelUsage, + key: ModelSourceUsageKey, + into result: inout [ModelSourceUsageKey: PerModelUsage]) { + var entry = result[key] ?? PerModelUsage() + entry.totalTokens += usage.totalTokens + entry.cost += usage.cost + entry.activeSeconds += usage.activeSeconds + entry.wallClockSeconds += usage.wallClockSeconds + let usageSources = Set(usage.sources.compactMap(\.trimmedNonEmpty)) + if usageSources.isEmpty { + entry.sources.insert(key.source) + } else { + entry.sources.formUnion(usageSources) + } + result[key] = entry +} diff --git a/TokiTests/ActivityTimeEstimatorTests.swift b/TokiTests/ActivityTimeEstimatorTests.swift index 04a01c0..f75edf5 100644 --- a/TokiTests/ActivityTimeEstimatorTests.swift +++ b/TokiTests/ActivityTimeEstimatorTests.swift @@ -72,6 +72,47 @@ final class ActivityTimeEstimatorTests: XCTestCase { XCTAssertEqual(estimate.secondsByKey["gpt-5.4"] ?? 0, 300, accuracy: 0.001) } + func test_activityTimeEstimator_mergesOverlappingStreamsPerKeyAsWallClock() { + // Two sessions on the same model overlap. Agent work time sums them, but the + // model was only in use for the merged span, so a per-model row must not + // report more time than actually elapsed. + let events = [ + ActivityTimeEvent(streamID: "thread-a", timestamp: isoDate("2026-04-10T00:00:00Z"), key: "gpt-5.4"), + ActivityTimeEvent(streamID: "thread-a", timestamp: isoDate("2026-04-10T00:02:00Z"), key: "gpt-5.4"), + ActivityTimeEvent(streamID: "thread-b", timestamp: isoDate("2026-04-10T00:01:00Z"), key: "gpt-5.4"), + ActivityTimeEvent(streamID: "thread-b", timestamp: isoDate("2026-04-10T00:03:00Z"), key: "gpt-5.4"), + ] + + let estimate = ActivityTimeEstimator.estimate(events: events) + + XCTAssertEqual(estimate.secondsByKey["gpt-5.4"] ?? 0, 300, accuracy: 0.001) + XCTAssertEqual(estimate.wallClockSecondsByKey["gpt-5.4"] ?? 0, 210, accuracy: 0.001) + XCTAssertEqual(estimate.wallClockSeconds, 210, accuracy: 0.001) + } + + func test_activityTimeEstimator_keepsPerKeyWallClockSeparatePerModel() { + // Distinct models overlapping in time each keep their own merged span, so the + // per-model spans may sum to more than the overall wall clock. + let events = [ + ActivityTimeEvent(streamID: "thread-a", timestamp: isoDate("2026-04-10T00:00:00Z"), key: "gpt-5.4"), + ActivityTimeEvent(streamID: "thread-a", timestamp: isoDate("2026-04-10T00:02:00Z"), key: "gpt-5.4"), + ActivityTimeEvent( + streamID: "thread-b", + timestamp: isoDate("2026-04-10T00:01:00Z"), + key: "claude-sonnet-4-6"), + ActivityTimeEvent( + streamID: "thread-b", + timestamp: isoDate("2026-04-10T00:03:00Z"), + key: "claude-sonnet-4-6"), + ] + + let estimate = ActivityTimeEstimator.estimate(events: events) + + XCTAssertEqual(estimate.wallClockSecondsByKey["gpt-5.4"] ?? 0, 150, accuracy: 0.001) + XCTAssertEqual(estimate.wallClockSecondsByKey["claude-sonnet-4-6"] ?? 0, 150, accuracy: 0.001) + XCTAssertEqual(estimate.wallClockSeconds, 210, accuracy: 0.001) + } + func test_activityTimeEstimator_splitsMainAndSubagentWorkTime() { let events = [ ActivityTimeEvent( diff --git a/TokiTests/UsageOriginAggregationTests.swift b/TokiTests/UsageOriginAggregationTests.swift index 770d3bf..fb2af17 100644 --- a/TokiTests/UsageOriginAggregationTests.swift +++ b/TokiTests/UsageOriginAggregationTests.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import Foundation import TokiUsageCore import XCTest @@ -109,6 +110,53 @@ final class UsageOriginAggregationTests: XCTestCase { XCTAssertEqual(Set(remoteRows.map(\.totalTokens)), [10, 20]) } + func test_aggregatorMergesOverlappingLocalAndRemoteTimeIntoOneWallClockSpan() async throws { + // The local machine and a remote device work on the same model at overlapping + // times. Agent work time adds both, but the elapsed span is the union, so a row + // must never claim more time than the calendar allows. + let localUsage = mockActivityUsage( + totalTokens: 10, + modelID: "shared-model", + source: "Codex", + events: [ + ActivityTimeEvent( + streamID: "local-1", + timestamp: tokiTestISODate("2026-07-01T00:00:00Z"), + key: "shared-model"), + ActivityTimeEvent( + streamID: "local-1", + timestamp: tokiTestISODate("2026-07-01T00:02:00Z"), + key: "shared-model"), + ]) + let remoteUsage = mockActivityUsage( + totalTokens: 20, + modelID: "shared-model", + source: "Codex", + events: [ + ActivityTimeEvent( + streamID: "remote-a:r-1", + timestamp: tokiTestISODate("2026-07-01T00:01:00Z"), + key: "shared-model"), + ActivityTimeEvent( + streamID: "remote-a:r-1", + timestamp: tokiTestISODate("2026-07-01T00:03:00Z"), + key: "shared-model"), + ]) + let aggregator = UsageAggregator(readers: [ + FixedUsageReader(name: "Codex", usage: localUsage), + FixedOriginReader( + name: "Remote Devices", + slices: [makeRemoteSlice(deviceID: "remote-a", name: "worker", usage: remoteUsage)]), + ]) + + let result = await aggregator.aggregateUsage(for: makeRequest(interval: testInterval)) + let row = try XCTUnwrap(result.usageData.perModel.first { $0.modelID == "shared-model" }) + + XCTAssertEqual(row.activeSeconds, 300, accuracy: 0.001) + XCTAssertEqual(row.wallClockSeconds, 210, accuracy: 0.001) + XCTAssertEqual(row.reportedSeconds, 210, accuracy: 0.001) + } + func test_unattributedModelKeyStaysCanonicalAcrossLocalAndRemoteAggregation() async throws { let interval = testInterval let groupingKey = UsageModelGrouping.mixedOrUnattributedKey diff --git a/TokiTests/UsageServiceActiveTimeTests.swift b/TokiTests/UsageServiceActiveTimeTests.swift index 768d34d..7ba6fc5 100644 --- a/TokiTests/UsageServiceActiveTimeTests.swift +++ b/TokiTests/UsageServiceActiveTimeTests.swift @@ -184,6 +184,42 @@ final class UsageServiceActiveTimeTests: XCTestCase { XCTAssertEqual(Set(service.usageData.perModel.map(\.id)), ["gpt-5.4|First", "gpt-5.4|Second"]) XCTAssertEqual(service.usageData.perModel.map(\.activeSeconds).reduce(0, +), 60, accuracy: 0.001) } + + @MainActor + func test_usageReportBoundsModelRowTimeToElapsedWindowWhenSessionsRunConcurrently() async { + // Three sessions of one model run over the same two minutes. Agent work time is + // three times the window, but the row must report the window itself and express + // the parallelism as a multiplier instead. + let streamIDs = ["session-a", "session-b", "session-c"] + let events = streamIDs.flatMap { streamID in + [ + ActivityTimeEvent( + streamID: streamID, + timestamp: usageServiceActiveTimeISODate("2026-04-10T00:00:00Z"), + key: "gpt-5.4"), + ActivityTimeEvent( + streamID: streamID, + timestamp: usageServiceActiveTimeISODate("2026-04-10T00:02:00Z"), + key: "gpt-5.4"), + ] + } + let reader = MockReader(name: "Codex", recorder: MockReaderRecorder()) { _, _ in + mockActivityUsage( + totalTokens: 300, + modelID: "gpt-5.4", + source: "Codex", + events: events) + } + let service = UsageService(readers: [reader]) + + await service.refresh() + + let stat = try? XCTUnwrap(service.usageData.perModel.first) + XCTAssertEqual(stat?.activeSeconds ?? 0, 450, accuracy: 0.001) + XCTAssertEqual(stat?.wallClockSeconds ?? 0, 150, accuracy: 0.001) + XCTAssertEqual(stat?.reportedSeconds ?? 0, 150, accuracy: 0.001) + XCTAssertEqual(stat?.parallelMultiplier ?? 0, 3, accuracy: 0.001) + } } @MainActor @@ -403,6 +439,28 @@ final class UsageModelActiveTimeReportTests: XCTestCase { } } +final class RawTokenUsageFallbackTimeTests: XCTestCase { + func test_recomputeKeepsModelWallClockForFallbackOnlyUsage() { + // A reader that reports totals without timestamps contributes no activity + // events, so the recompute returns before the estimate runs. The per-model + // wall clock still has to carry the fallback duration; otherwise the export + // column reports an unmeasured zero for time that was in fact measured. + var fallbackOnly = RawTokenUsage() + fallbackOnly.perModel["gpt-5.4"] = PerModelUsage( + totalTokens: 100, + activeSeconds: 120, + sources: ["Cursor"]) + + var combined = RawTokenUsage() + combined += fallbackOnly + combined.recomputeMergedActiveEstimate() + + XCTAssertTrue(combined.activityEvents.isEmpty) + XCTAssertEqual(combined.perModel["gpt-5.4"]?.activeSeconds ?? 0, 120, accuracy: 0.001) + XCTAssertEqual(combined.perModel["gpt-5.4"]?.wallClockSeconds ?? 0, 120, accuracy: 0.001) + } +} + private func usageServiceActiveTimeISODate(_ value: String) -> Date { guard let date = DateParser.parse(value) else { XCTFail("Failed to parse ISO date: \(value)")