106 lines
4.7 KiB
Swift
106 lines
4.7 KiB
Swift
|
|
import Foundation
|
||
|
|
|
||
|
|
public enum TokenUsageParser {
|
||
|
|
public static func parseClaudeLine(_ line: String, sourcePath: String) -> UsageRecord? {
|
||
|
|
guard let object = parseJSONObject(line) else { return nil }
|
||
|
|
let message = object["message"] as? [String: Any]
|
||
|
|
let usage = message?["usage"] as? [String: Any] ?? object["usage"] as? [String: Any]
|
||
|
|
guard let usage else { return nil }
|
||
|
|
guard let timestamp = parseDate(object["timestamp"] ?? object["createdAt"] ?? object["date"]) else { return nil }
|
||
|
|
|
||
|
|
return UsageRecord(
|
||
|
|
provider: .claude,
|
||
|
|
source: sourceLabel(sourcePath: sourcePath, provider: .claude),
|
||
|
|
sourcePath: sourcePath,
|
||
|
|
timestamp: timestamp,
|
||
|
|
sessionID: stringValue(object["sessionId"]),
|
||
|
|
model: stringValue(message?["model"]) == "" ? "Claude" : stringValue(message?["model"]),
|
||
|
|
inputTokens: intValue(usage["input_tokens"]),
|
||
|
|
outputTokens: intValue(usage["output_tokens"]),
|
||
|
|
cachedTokens: intValue(usage["cache_read_input_tokens"]) + intValue(usage["cache_creation_input_tokens"]),
|
||
|
|
reasoningTokens: 0,
|
||
|
|
toolTokens: 0
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
public static func parseCodexLine(_ line: String, sourcePath: String) -> UsageRecord? {
|
||
|
|
guard let object = parseJSONObject(line),
|
||
|
|
let payload = object["payload"] as? [String: Any],
|
||
|
|
let info = payload["info"] as? [String: Any],
|
||
|
|
let usage = info["last_token_usage"] as? [String: Any],
|
||
|
|
let timestamp = parseDate(object["timestamp"])
|
||
|
|
else { return nil }
|
||
|
|
|
||
|
|
return UsageRecord(
|
||
|
|
provider: .codex,
|
||
|
|
source: sourceLabel(sourcePath: sourcePath, provider: .codex),
|
||
|
|
sourcePath: sourcePath,
|
||
|
|
timestamp: timestamp,
|
||
|
|
sessionID: stringValue(payload["id"] ?? payload["thread_id"]),
|
||
|
|
model: stringValue(payload["model"]) == "" ? "Codex" : stringValue(payload["model"]),
|
||
|
|
inputTokens: intValue(usage["input_tokens"]),
|
||
|
|
outputTokens: intValue(usage["output_tokens"]),
|
||
|
|
cachedTokens: intValue(usage["cached_input_tokens"]),
|
||
|
|
reasoningTokens: intValue(usage["reasoning_output_tokens"]),
|
||
|
|
toolTokens: 0
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
public static func parseGeminiLine(_ line: String, sourcePath: String) -> UsageRecord? {
|
||
|
|
guard let object = parseJSONObject(line),
|
||
|
|
let tokens = object["tokens"] as? [String: Any],
|
||
|
|
let timestamp = parseDate(object["timestamp"])
|
||
|
|
else { return nil }
|
||
|
|
|
||
|
|
return UsageRecord(
|
||
|
|
provider: .gemini,
|
||
|
|
source: sourceLabel(sourcePath: sourcePath, provider: .gemini),
|
||
|
|
sourcePath: sourcePath,
|
||
|
|
timestamp: timestamp,
|
||
|
|
sessionID: stringValue(object["id"]),
|
||
|
|
model: stringValue(object["model"]) == "" ? "Gemini" : stringValue(object["model"]),
|
||
|
|
inputTokens: intValue(tokens["input"]),
|
||
|
|
outputTokens: intValue(tokens["output"]),
|
||
|
|
cachedTokens: intValue(tokens["cached"]),
|
||
|
|
reasoningTokens: intValue(tokens["thoughts"]),
|
||
|
|
toolTokens: intValue(tokens["tool"]),
|
||
|
|
explicitTotal: intValue(tokens["total"]) == 0 ? nil : intValue(tokens["total"])
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
static func parseDate(_ value: Any?) -> Date? {
|
||
|
|
guard let text = value as? String else { return nil }
|
||
|
|
let isoFormatter = ISO8601DateFormatter()
|
||
|
|
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
|
|
let fallbackISOFormatter = ISO8601DateFormatter()
|
||
|
|
fallbackISOFormatter.formatOptions = [.withInternetDateTime]
|
||
|
|
return isoFormatter.date(from: text) ?? fallbackISOFormatter.date(from: text)
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func parseJSONObject(_ line: String) -> [String: Any]? {
|
||
|
|
guard let data = line.data(using: .utf8) else { return nil }
|
||
|
|
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func stringValue(_ value: Any?) -> String {
|
||
|
|
value as? String ?? ""
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func intValue(_ value: Any?) -> Int {
|
||
|
|
if let int = value as? Int { return int }
|
||
|
|
if let double = value as? Double { return Int(double) }
|
||
|
|
if let string = value as? String { return Int(string) ?? 0 }
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func sourceLabel(sourcePath: String, provider: Provider) -> String {
|
||
|
|
switch provider {
|
||
|
|
case .claude: return "projects/*.jsonl"
|
||
|
|
case .codex: return "sessions/*.jsonl"
|
||
|
|
case .gemini: return "tmp/chats/*.jsonl"
|
||
|
|
case .hermes: return "state.db/sessions"
|
||
|
|
case .opencode: return "opencode.db/session"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|