@@ -36,6 +36,13 @@ enum PiSessionCostScanner {
3636 let cacheRoot : URL ?
3737 }
3838
39+ private struct ScanContext {
40+ let range : CostUsageScanner . CostUsageDayRange
41+ let forceRescan : Bool
42+ let pricingContext : ModelsDevPricingContext
43+ let checkCancellation : CostUsageScanner . CancellationCheck ?
44+ }
45+
3946 private static let costScale = 1_000_000_000.0
4047 private static let maxLineBytes = 16 * 1024 * 1024
4148 private static let maxSafeRoundedInt = Double ( Int . max) - 1
@@ -46,6 +53,24 @@ enum PiSessionCostScanner {
4653 until: Date ,
4754 now: Date = Date ( ) ,
4855 options: Options = Options ( ) ) -> CostUsageDailyReport
56+ {
57+ (
58+ try ? self . loadDailyReportCancellable (
59+ provider: provider,
60+ since: since,
61+ until: until,
62+ now: now,
63+ options: options,
64+ checkCancellation: nil ) ) ?? CostUsageDailyReport ( data: [ ] , summary: nil )
65+ }
66+
67+ static func loadDailyReportCancellable(
68+ provider: UsageProvider ,
69+ since: Date ,
70+ until: Date ,
71+ now: Date = Date ( ) ,
72+ options: Options = Options ( ) ,
73+ checkCancellation: CostUsageScanner . CancellationCheck ? ) throws -> CostUsageDailyReport
4974 {
5075 guard provider == . codex || provider == . claude else {
5176 return CostUsageDailyReport ( data: [ ] , summary: nil )
@@ -66,19 +91,23 @@ enum PiSessionCostScanner {
6691 || nowMs - cache. lastScanUnixMs > refreshMs
6792
6893 if shouldRefresh {
94+ try checkCancellation ? ( )
6995 let root = self . defaultPiSessionsRoot ( options: options)
7096 let startCutoff = self . dateFromDayKey ( range. scanSinceKey) ?? since
7197 let files = self . listPiSessionFiles ( root: root, startCutoffLocal: startCutoff)
7298 let filePathsInScan = Set ( files. map ( \. path) )
7399
74100 for fileURL in files {
75- self . scanPiSessionFile (
101+ try self . scanPiSessionFile (
76102 fileURL: fileURL,
77- range: range,
78- forceRescan: options. forceRescan || windowExpanded,
79- pricingContext: pricingContext,
80- cache: & cache)
103+ cache: & cache,
104+ context: ScanContext (
105+ range: range,
106+ forceRescan: options. forceRescan || windowExpanded,
107+ pricingContext: pricingContext,
108+ checkCancellation: checkCancellation) )
81109 }
110+ try checkCancellation ? ( )
82111
83112 for key in cache. files. keys where !filePathsInScan. contains ( key) {
84113 if let old = cache. files [ key] {
@@ -93,6 +122,7 @@ enum PiSessionCostScanner {
93122 cache. scanSinceKey = range. scanSinceKey
94123 cache. scanUntilKey = range. scanUntilKey
95124 cache. lastScanUnixMs = nowMs
125+ try checkCancellation ? ( )
96126 PiSessionCostCacheIO . save ( cache: cache, cacheRoot: options. cacheRoot)
97127 }
98128
@@ -176,11 +206,11 @@ enum PiSessionCostScanner {
176206
177207 private static func scanPiSessionFile(
178208 fileURL: URL ,
179- range: CostUsageScanner . CostUsageDayRange ,
180- forceRescan: Bool ,
181- pricingContext: ModelsDevPricingContext ,
182- cache: inout PiSessionCostCache )
209+ cache: inout PiSessionCostCache ,
210+ context: ScanContext )
211+ throws
183212 {
213+ try context. checkCancellation ? ( )
184214 let path = fileURL. path
185215 let attrs = ( try ? FileManager . default. attributesOfItem ( atPath: path) ) ?? [ : ]
186216 let mtime = ( attrs [ . modificationDate] as? Date ) ? . timeIntervalSince1970 ?? 0
@@ -192,26 +222,27 @@ enum PiSessionCostScanner {
192222 }
193223
194224 let cached = cache. files [ path]
195- if !forceRescan,
225+ if !context . forceRescan,
196226 let cached,
197227 cached. mtimeUnixMs == mtimeMs,
198228 cached. size == size
199229 {
200230 return
201231 }
202232
203- if !forceRescan,
233+ if !context . forceRescan,
204234 let cached,
205235 size > cached. size,
206236 cached. parsedBytes > 0 ,
207237 cached. parsedBytes <= size
208238 {
209- let delta = self . parsePiSessionFile (
239+ let delta = try self . parsePiSessionFile (
210240 fileURL: fileURL,
211- range: range,
241+ range: context . range,
212242 startOffset: cached. parsedBytes,
213243 initialModelContext: cached. lastModelContext,
214- pricingContext: pricingContext)
244+ pricingContext: context. pricingContext,
245+ checkCancellation: context. checkCancellation)
215246 if !delta. contributions. isEmpty {
216247 self . applyContributions (
217248 daysByProvider: & cache. daysByProvider,
@@ -235,10 +266,11 @@ enum PiSessionCostScanner {
235266 sign: - 1 )
236267 }
237268
238- let parsed = self . parsePiSessionFile (
269+ let parsed = try self . parsePiSessionFile (
239270 fileURL: fileURL,
240- range: range,
241- pricingContext: pricingContext)
271+ range: context. range,
272+ pricingContext: context. pricingContext,
273+ checkCancellation: context. checkCancellation)
242274 if !parsed. contributions. isEmpty {
243275 self . applyContributions ( daysByProvider: & cache. daysByProvider, contributions: parsed. contributions, sign: 1 )
244276 }
@@ -256,7 +288,8 @@ enum PiSessionCostScanner {
256288 range: CostUsageScanner . CostUsageDayRange ,
257289 startOffset: Int64 = 0 ,
258290 initialModelContext: PiModelContext ? = nil ,
259- pricingContext: ModelsDevPricingContext ? = nil ) -> ParseResult
291+ pricingContext: ModelsDevPricingContext ? = nil ,
292+ checkCancellation: CostUsageScanner . CancellationCheck ? = nil ) throws -> ParseResult
260293 {
261294 var currentModelContext = initialModelContext
262295 var contributions : [ String : [ String : [ String : PiPackedUsage ] ] ] = [ : ]
@@ -292,41 +325,49 @@ enum PiSessionCostScanner {
292325 }
293326 }
294327
295- let parsedBytes = ( try ? CostUsageJsonl . scan (
296- fileURL: fileURL,
297- offset: startOffset,
298- maxLineBytes: Self . maxLineBytes,
299- prefixBytes: Self . maxLineBytes,
300- onLine: { line in
301- guard !line. bytes. isEmpty, !line. wasTruncated else { return }
302- autoreleasepool {
303- guard let object = ( try ? JSONSerialization . jsonObject ( with: line. bytes) ) as? [ String : Any ]
304- else { return }
305- guard let type = object [ " type " ] as? String else { return }
306-
307- if type == " model_change " {
308- currentModelContext = self . modelContext ( from: object)
309- return
328+ let parsedBytes : Int64
329+ do {
330+ parsedBytes = try CostUsageJsonl . scan (
331+ fileURL: fileURL,
332+ offset: startOffset,
333+ maxLineBytes: Self . maxLineBytes,
334+ prefixBytes: Self . maxLineBytes,
335+ checkCancellation: checkCancellation,
336+ onLine: { line in
337+ guard !line. bytes. isEmpty, !line. wasTruncated else { return }
338+ autoreleasepool {
339+ guard let object = ( try ? JSONSerialization . jsonObject ( with: line. bytes) ) as? [ String : Any ]
340+ else { return }
341+ guard let type = object [ " type " ] as? String else { return }
342+
343+ if type == " model_change " {
344+ currentModelContext = self . modelContext ( from: object)
345+ return
346+ }
347+
348+ guard type == " message " , let message = object [ " message " ] as? [ String : Any ] else { return }
349+ guard ( message [ " role " ] as? String ) == " assistant " else { return }
350+
351+ let identity = self . resolveAssistantIdentity (
352+ entry: object,
353+ message: message,
354+ fallback: currentModelContext)
355+ guard let identity else { return }
356+ guard let date = self . timestampDate ( entry: object, message: message) else { return }
357+ let dayKey = CostUsageScanner . CostUsageDayRange. dayKey ( from: date)
358+ let usage = self . extractUsage (
359+ provider: identity. provider,
360+ modelName: identity. modelName,
361+ message: message,
362+ pricingContext: pricingContext)
363+ add ( provider: identity. provider, dayKey: dayKey, modelName: identity. modelName, usage: usage)
310364 }
311-
312- guard type == " message " , let message = object [ " message " ] as? [ String : Any ] else { return }
313- guard ( message [ " role " ] as? String ) == " assistant " else { return }
314-
315- let identity = self . resolveAssistantIdentity (
316- entry: object,
317- message: message,
318- fallback: currentModelContext)
319- guard let identity else { return }
320- guard let date = self . timestampDate ( entry: object, message: message) else { return }
321- let dayKey = CostUsageScanner . CostUsageDayRange. dayKey ( from: date)
322- let usage = self . extractUsage (
323- provider: identity. provider,
324- modelName: identity. modelName,
325- message: message,
326- pricingContext: pricingContext)
327- add ( provider: identity. provider, dayKey: dayKey, modelName: identity. modelName, usage: usage)
328- }
329- } ) ) ?? startOffset
365+ } )
366+ } catch is CancellationError {
367+ throw CancellationError ( )
368+ } catch {
369+ parsedBytes = startOffset
370+ }
330371
331372 return ParseResult (
332373 contributions: contributions,
0 commit comments