diff --git a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift index 3bdc073..7e1b642 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift @@ -47,6 +47,7 @@ struct DailyUsage: Codable, Identifiable { var date: String var tools: [String: Int] var models: [String: Int] + var toolModels: [String: [String: Int]]? var totalTokens: Int var cost: Double @@ -54,14 +55,16 @@ struct DailyUsage: Codable, Identifiable { case date case tools case models + case toolModels = "tool_models" case totalTokens = "total_tokens" case cost } - init(date: String, tools: [String: Int], models: [String: Int] = [:], totalTokens: Int, cost: Double) { + init(date: String, tools: [String: Int], models: [String: Int] = [:], toolModels: [String: [String: Int]]? = nil, totalTokens: Int, cost: Double) { self.date = date self.tools = tools self.models = models + self.toolModels = toolModels self.totalTokens = totalTokens self.cost = cost } @@ -71,6 +74,7 @@ struct DailyUsage: Codable, Identifiable { date = try container.decode(String.self, forKey: .date) tools = try container.decodeIfPresent([String: Int].self, forKey: .tools) ?? [:] models = try container.decodeIfPresent([String: Int].self, forKey: .models) ?? [:] + toolModels = try container.decodeIfPresent([String: [String: Int]].self, forKey: .toolModels) totalTokens = try container.decode(Int.self, forKey: .totalTokens) cost = try container.decode(Double.self, forKey: .cost) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift index 9e0a17f..fd96bb2 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift @@ -15,13 +15,17 @@ enum UsageCollector { let sourceCutoff = sourceFileCutoffDate(historyDays: historyDays) let codex = collectCodex(cache: &cache, livePaths: &livePaths, modifiedSince: sourceCutoff) let claude = collectClaudeCode(cache: &cache, livePaths: &livePaths, modifiedSince: sourceCutoff) + let kimi = collectKimiCode(cache: &cache, livePaths: &livePaths, modifiedSince: sourceCutoff) + let zcode = collectZCode(modifiedSince: sourceCutoff) + let pi = collectPi(cache: &cache, livePaths: &livePaths, modifiedSince: sourceCutoff) + let reasonix = collectReasonix(cache: &cache, livePaths: &livePaths, modifiedSince: sourceCutoff) var ccSwitch = includeCCSwitchProxyUsage ? collectCCSwitchProxyUsage(databaseURL: ccSwitchDatabaseURL) : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) cache.files = cache.files.filter { livePaths.contains($0.key) } saveCache(cache) - let nativeRecords = codex.records + claude.records + let nativeRecords = codex.records + claude.records + kimi.records + zcode.records + pi.records + reasonix.records let deduped = deduplicateCrossSource( nativeRecords: nativeRecords, proxyRecords: ccSwitch.records @@ -34,6 +38,10 @@ enum UsageCollector { sources: [ "Codex": codex.source, "Claude Code": claude.source, + "Kimi Code": kimi.source, + "ZCode": zcode.source, + "Pi": pi.source, + "Reasonix": reasonix.source, ccSwitchSourceName: ccSwitch.source ] ) @@ -120,7 +128,7 @@ enum UsageCollector { return nil } - let query = "select created_at, model, tokens_used from threads where tokens_used > 0" + let query = "select created_at, model, model_provider, tokens_used from threads where tokens_used > 0" let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") process.arguments = ["-readonly", "-json", database.path, query] @@ -151,11 +159,13 @@ enum UsageCollector { } var usage = TokenUsageCounts() usage.totalTokens = tokens + let explicitModel = (row["model"] as? String).flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } + let fallbackModel = (row["model_provider"] as? String).flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } return UsageRecord( date: day, timestamp: nil, tool: "Codex", - model: modelKey(row["model"] as? String), + model: modelKey(explicitModel ?? fallbackModel), usage: usage, source: .nativeCodexSQLite ) @@ -208,6 +218,9 @@ enum UsageCollector { if type == "session_meta", let id = payload?["id"] as? String, !id.isEmpty { sessionID = id } + if currentModel == "unknown", let provider = payload?["model_provider"] as? String, !provider.isEmpty { + currentModel = modelKey(provider) + } if type == "turn_context" { currentModel = modelKey(payload?["model"] as? String ?? currentModel) } @@ -345,6 +358,391 @@ enum UsageCollector { ) } + private static func collectKimiCode( + cache: inout CollectorCache, + livePaths: inout Set, + modifiedSince cutoffDate: Date? + ) -> CollectorResult { + let home = FileManager.default.homeDirectoryForCurrentUser + let roots: [(url: URL, tool: String)] = [ + ( + home.appendingPathComponent( + "Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home/sessions", + isDirectory: true + ), + "Kimi Code" + ), + ( + home.appendingPathComponent(".kimi-code/sessions", isDirectory: true), + "Kimi Code CLI" + ) + ] + let modelMapping = kimiModelMapping() + var records: [UsageRecord] = [] + var seen = Set() + var totalFiles = 0 + + for (root, tool) in roots { + let paths = jsonlFiles(under: root, modifiedSince: cutoffDate) + .filter { $0.path.hasSuffix("/agents/main/wire.jsonl") } + totalFiles += paths.count + + for path in paths.sorted(by: { $0.path < $1.path }) { + livePaths.insert(path.path) + if let cached = cachedRecords(for: path, tool: tool, cache: cache) { + records.append(contentsOf: cached) + continue + } + + var fileRecords: [UsageRecord] = [] + let sessionID = path.deletingPathExtension().deletingPathExtension().deletingPathExtension().lastPathComponent + var lineNumber = 0 + guard FileManager.default.isReadableFile(atPath: path.path) else { continue } + + try? forEachLine(in: path, matchingAny: ["usage.record"]) { line in + autoreleasepool { + lineNumber += 1 + guard let obj = jsonObject(line), + obj["type"] as? String == "usage.record", + let usageRaw = obj["usage"] as? [String: Any] + else { + return + } + let usage = normalizeUsage(usageRaw) + guard usage.totalTokens > 0, + let ts = obj["time"], + let day = dayString(fromEpoch: ts) + else { + return + } + let rawModel = modelKey(obj["model"] as? String) + let model = modelMapping[rawModel] ?? canonicalModelName(rawModel) + let key = "\(sessionID)|\(ts)|\(lineNumber)|\(usage.totalTokens)" + guard !seen.contains(key) else { return } + seen.insert(key) + let timestampString: String? = { + guard let seconds = epochSeconds(ts) else { return nil } + return isoString(fromEpoch: seconds) + }() + fileRecords.append( + UsageRecord( + date: day, + timestamp: timestampString, + tool: tool, + model: model, + usage: usage, + source: .nativeKimiCode, + requestID: key, + sessionID: sessionID, + sourcePath: path.path, + lineNumber: lineNumber + ) + ) + } + } + records.append(contentsOf: fileRecords) + updateCache(path: path, tool: tool, records: fileRecords, cache: &cache) + } + } + + return CollectorResult( + records: records, + source: SourceInfo( + status: records.isEmpty ? "missing" : "ok", + files: totalFiles, + records: records.count + ) + ) + } + + private static func kimiModelMapping() -> [String: String] { + let home = FileManager.default.homeDirectoryForCurrentUser + var mapping: [String: String] = [:] + + let desktopConfig = home.appendingPathComponent( + "Library/Application Support/kimi-desktop/daimon-share/daimon/config.json" + ) + if let data = try? Data(contentsOf: desktopConfig), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let modelConfig = json["model"] as? [String: Any], + let models = modelConfig["models"] as? [String: [String: Any]] { + for (key, entry) in models { + if let actual = entry["model"] as? String { + mapping[key] = actual + } + } + } + + let cliConfig = home.appendingPathComponent(".kimi-code/config.toml") + if let text = try? String(contentsOf: cliConfig, encoding: .utf8) { + let modelSectionPattern = #"^\[models\."([^"]+)"\]"# + let displayNamePattern = #"display_name\s*=\s*"([^"]+)""# + var currentKey: String? + for line in text.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.range(of: modelSectionPattern, options: .regularExpression) != nil, + let keyRange = trimmed.range(of: #""([^"]+)""#, options: .regularExpression) { + let start = trimmed.index(keyRange.lowerBound, offsetBy: 1) + let end = trimmed.index(keyRange.upperBound, offsetBy: -1) + currentKey = String(trimmed[start.. CollectorResult { + let home = FileManager.default.homeDirectoryForCurrentUser + let database = home.appendingPathComponent(".zcode/cli/db/db.sqlite") + + guard FileManager.default.isReadableFile(atPath: database.path) else { + return CollectorResult( + records: [], + source: SourceInfo(status: "missing", files: 0, records: 0) + ) + } + + guard let metadata = fileMetadata(for: database), + cutoffDate == nil || Date(timeIntervalSince1970: metadata.modificationTime) >= cutoffDate! + else { + return CollectorResult( + records: [], + source: SourceInfo(status: "ok", files: 1, records: 0) + ) + } + + let query = """ + SELECT + id, + session_id, + turn_id, + model_id, + provider_id, + started_at, + completed_at, + input_tokens, + output_tokens, + reasoning_tokens, + cache_creation_input_tokens, + cache_read_input_tokens, + computed_total_tokens + FROM model_usage + WHERE status = 'completed' + AND computed_total_tokens > 0 + """ + + guard let rows = sqliteJSONRows(database: database, query: query) else { + return CollectorResult( + records: [], + source: SourceInfo(status: "query_failed", files: 1, records: 0) + ) + } + + let records = rows.compactMap { row -> UsageRecord? in + guard let ts = row["completed_at"] as? Int ?? row["started_at"] as? Int, + let day = dayString(fromEpoch: ts) + else { + return nil + } + var usage = TokenUsageCounts() + usage.inputTokens = integerValue(row["input_tokens"] as Any) + usage.outputTokens = integerValue(row["output_tokens"] as Any) + usage.reasoningOutputTokens = integerValue(row["reasoning_tokens"] as Any) + usage.cacheCreationInputTokens = integerValue(row["cache_creation_input_tokens"] as Any) + usage.cacheReadInputTokens = integerValue(row["cache_read_input_tokens"] as Any) + usage.totalTokens = integerValue(row["computed_total_tokens"] as Any) + guard usage.totalTokens > 0 else { return nil } + + return UsageRecord( + date: day, + timestamp: isoString(fromEpoch: ts), + tool: "ZCode", + model: modelKey(row["model_id"] as? String), + usage: usage, + source: .nativeZCode, + requestID: nonEmptyString(row["id"] as? String), + sessionID: nonEmptyString(row["session_id"] as? String), + sourcePath: database.path + ) + } + + return CollectorResult( + records: records, + source: SourceInfo( + status: records.isEmpty ? "missing" : "ok", + files: 1, + records: records.count + ) + ) + } + + private static func collectPi( + cache: inout CollectorCache, + livePaths: inout Set, + modifiedSince cutoffDate: Date? + ) -> CollectorResult { + let home = FileManager.default.homeDirectoryForCurrentUser + let root = home.appendingPathComponent(".pi/agent/sessions", isDirectory: true) + let paths = jsonlFiles(under: root, modifiedSince: cutoffDate) + var records: [UsageRecord] = [] + var seen = Set() + + for path in paths.sorted(by: { $0.path < $1.path }) { + livePaths.insert(path.path) + if let cached = cachedRecords(for: path, tool: "Pi", cache: cache) { + records.append(contentsOf: cached) + continue + } + + var fileRecords: [UsageRecord] = [] + var lineNumber = 0 + guard FileManager.default.isReadableFile(atPath: path.path) else { continue } + + try? forEachLine(in: path, matchingAny: ["\"type\": \"message\"", "\"type\":\"message\""]) { line in + autoreleasepool { + lineNumber += 1 + guard let obj = jsonObject(line), + obj["type"] as? String == "message", + let message = obj["message"] as? [String: Any], + message["role"] as? String == "assistant", + let usageRaw = message["usage"] as? [String: Any] + else { + return + } + let usage = normalizeUsage(usageRaw) + guard usage.totalTokens > 0, + let timestamp = obj["timestamp"] as? String, + let day = dayString(fromISO: timestamp) + else { + return + } + let model = modelKey(message["model"] as? String) + let key = "\(path.path):\(lineNumber):\(usage.totalTokens)" + guard !seen.contains(key) else { return } + seen.insert(key) + fileRecords.append( + UsageRecord( + date: day, + timestamp: timestamp, + tool: "Pi", + model: model, + usage: usage, + source: .nativePi, + requestID: key, + sourcePath: path.path, + lineNumber: lineNumber + ) + ) + } + } + records.append(contentsOf: fileRecords) + updateCache(path: path, tool: "Pi", records: fileRecords, cache: &cache) + } + + return CollectorResult( + records: records, + source: SourceInfo( + status: records.isEmpty ? "missing" : "ok", + files: paths.count, + records: records.count + ) + ) + } + + private static func collectReasonix( + cache: inout CollectorCache, + livePaths: inout Set, + modifiedSince cutoffDate: Date? + ) -> CollectorResult { + let home = FileManager.default.homeDirectoryForCurrentUser + let roots = [ + home.appendingPathComponent(".reasonix/sessions", isDirectory: true), + home.appendingPathComponent(".reasonix/projects", isDirectory: true) + ] + let paths = roots.flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) } + .filter { $0.lastPathComponent.hasSuffix(".events.jsonl") } + var records: [UsageRecord] = [] + var seen = Set() + + for path in paths.sorted(by: { $0.path < $1.path }) { + livePaths.insert(path.path) + if let cached = cachedRecords(for: path, tool: "Reasonix", cache: cache) { + records.append(contentsOf: cached) + continue + } + + var fileRecords: [UsageRecord] = [] + var turnModel = [AnyHashable: String]() + var lineNumber = 0 + guard FileManager.default.isReadableFile(atPath: path.path) else { continue } + + try? forEachLine(in: path, matchingAny: ["model.turn.started", "model.final"]) { line in + autoreleasepool { + lineNumber += 1 + guard let obj = jsonObject(line) else { return } + let type = obj["type"] as? String + + if type == "model.turn.started", + let turn = obj["turn"] as? AnyHashable, + let model = obj["model"] as? String { + turnModel[turn] = model + } + + guard type == "model.final", + let usageRaw = obj["usage"] as? [String: Any] + else { + return + } + let usage = normalizeUsage(usageRaw) + guard usage.totalTokens > 0, + let timestamp = obj["ts"] as? String, + let day = dayString(fromISO: timestamp) + else { + return + } + let turn = obj["turn"] as? AnyHashable + let model = modelKey(turn.flatMap { turnModel[$0] } ?? obj["model"] as? String) + let key = "\(path.path):\(String(describing: turn)):\(timestamp):\(usage.totalTokens)" + guard !seen.contains(key) else { return } + seen.insert(key) + fileRecords.append( + UsageRecord( + date: day, + timestamp: timestamp, + tool: "Reasonix", + model: model, + usage: usage, + source: .nativeReasonix, + requestID: key, + sourcePath: path.path, + lineNumber: lineNumber + ) + ) + } + } + records.append(contentsOf: fileRecords) + updateCache(path: path, tool: "Reasonix", records: fileRecords, cache: &cache) + } + + return CollectorResult( + records: records, + source: SourceInfo( + status: records.isEmpty ? "missing" : "ok", + files: paths.count, + records: records.count + ) + ) + } + private static func collectCCSwitchProxyUsage(databaseURL: URL? = nil) -> CollectorResult { let database = databaseURL ?? FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".cc-switch/cc-switch.db") @@ -683,6 +1081,7 @@ enum UsageCollector { date: item.date, tools: item.tools, models: item.models, + toolModels: item.toolModels, totalTokens: item.totalTokens, cost: rounded(item.cost, digits: 4) ) @@ -891,10 +1290,19 @@ enum UsageCollector { var usage = TokenUsageCounts() let aliases = [ "input": "inputTokens", + "inputOther": "inputTokens", "output": "outputTokens", "cached": "cacheReadInputTokens", + "cacheRead": "cacheReadInputTokens", + "inputCacheRead": "cacheReadInputTokens", + "prompt_cache_hit_tokens": "cacheReadInputTokens", + "cacheWrite": "cacheCreationInputTokens", + "inputCacheCreation": "cacheCreationInputTokens", + "prompt_cache_miss_tokens": "cacheCreationInputTokens", "thoughts": "reasoningOutputTokens", + "reasoning_tokens": "reasoningOutputTokens", "total": "totalTokens", + "totalTokens": "totalTokens", "input_tokens": "inputTokens", "output_tokens": "outputTokens", "cache_creation_input_tokens": "cacheCreationInputTokens", @@ -988,8 +1396,74 @@ enum UsageCollector { } private static func modelKey(_ model: String?) -> String { - let value = (model ?? "unknown").trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? "unknown" : value + canonicalModelName(model) + } + + private static func canonicalModelName(_ model: String?) -> String { + let raw = (model ?? "unknown").trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty { return "unknown" } + let stripped = raw.hasPrefix("custom-local:") + ? String(raw.dropFirst("custom-local:".count)) + : raw + let lower = stripped.lowercased() + let aliases: [String: String] = [ + "daimon-kimi-code": "Kimi 2.6", + "k2p6": "Kimi 2.6", + "kimi-k2.6": "Kimi 2.6", + "kimi-k2.7": "Kimi 2.7", + "kimi-code/kimi-for-coding": "Kimi for coding", + "kimi-for-coding": "Kimi for coding", + "kimi-k2.5": "Kimi 2.5", + "kimi-k2-thinking": "Kimi 2.0 Thinking", + "kimi-k2-instruct-taiji": "Kimi 2.0 Instruct Taiji", + "deepseek-v4-pro": "DeepSeek V4 Pro", + "deepseek-v4-flash": "DeepSeek V4 Flash", + "deepseek-v3-2-volc": "DeepSeek V3.2 Volc", + "deepseek-v3-1-volc": "DeepSeek V3.1 Volc", + "deepseek-v3-1-lkeap": "DeepSeek V3.1 LKeap", + "deepseek-v3-1": "DeepSeek V3.1", + "deepseek-v3-0324-lkeap": "DeepSeek V3-0324 LKeap", + "deepseek-v3-0324": "DeepSeek V3-0324", + "deepseek-v3-0324-taco-completion": "DeepSeek V3-0324 Taco", + "deepseek-r1-0528-lkeap": "DeepSeek R1-0528 LKeap", + "deepseek-r1-0528": "DeepSeek R1-0528", + "glm-5.2": "GLM-5.2", + "glm-5.1": "GLM-5.1", + "glm-5.0": "GLM-5.0", + "glm-5.0-turbo": "GLM-5.0 Turbo", + "glm-5v-turbo": "GLM-5V Turbo", + "glm-4.7": "GLM-4.7", + "glm-4.6": "GLM-4.6", + "glm-4.6v": "GLM-4.6V", + "glm": "GLM", + "gpt-5.5": "GPT-5.5", + "gpt-5.4": "GPT-5.4", + "gpt-5": "GPT-5", + "gpt-5-codex": "GPT-5 Codex", + "gpt-5.4-mini": "GPT-5.4 Mini", + "codex-auto-review": "Codex Auto-Review", + "claude-opus": "Claude Opus", + "claude-sonnet": "Claude Sonnet", + "claude-3-opus": "Claude 3 Opus", + "claude-3-sonnet": "Claude 3 Sonnet", + "claude-3.5-sonnet": "Claude 3.5 Sonnet", + "minimax-m2.7": "MiniMax M2.7", + "minimax-m2.5": "MiniMax M2.5", + "minimax": "MiniMax", + "hunyuan-chat": "Hunyuan Chat", + "hunyuan-2.0-thinking": "Hunyuan 2.0 Thinking", + "hunyuan-2.0-instruct": "Hunyuan 2.0 Instruct", + "hunyuan-image-v3.0": "Hunyuan Image V3.0", + "hunyuan-3b": "Hunyuan 3B", + "hunyuan-7b-dense": "Hunyuan 7B Dense", + "auto": "Auto", + "default": "Default", + "default-1.1": "Default 1.1", + "default-1.2": "Default 1.2", + "lite": "Lite", + "openai": "OpenAI", + ] + return aliases[lower] ?? stripped } private static func claudeIdentity( @@ -1098,6 +1572,15 @@ enum UsageCollector { if tool == "Claude Code" { return Double(usage.totalTokens) / 1_000_000 * 3 } + if tool == "Kimi Code" || tool == "Kimi Code CLI" { + return Double(usage.totalTokens) / 1_000_000 * 0.8 + } + if tool == "ZCode" || lower.contains("glm") { + return Double(usage.totalTokens) / 1_000_000 * 0.5 + } + if tool == "Pi" || tool == "Reasonix" || lower.contains("deepseek") { + return Double(usage.totalTokens) / 1_000_000 * 0.5 + } return Double(usage.totalTokens) / 1_000_000 } @@ -1199,6 +1682,10 @@ private enum UsageRecordSource: String, Codable { case nativeCodex case nativeCodexSQLite case nativeClaudeCode + case nativeKimiCode + case nativeZCode + case nativePi + case nativeReasonix case ccSwitchProxy case unknown } @@ -1294,12 +1781,14 @@ private struct DailyAccumulator { var date: String var tools: [String: Int] = [:] var models: [String: Int] = [:] + var toolModels: [String: [String: Int]] = [:] var totalTokens = 0 var cost = 0.0 mutating func add(record: UsageRecord, cost: Double) { tools[record.tool, default: 0] += record.usage.totalTokens models[record.model, default: 0] += record.usage.totalTokens + toolModels[record.tool, default: [:]][record.model, default: 0] += record.usage.totalTokens totalTokens += record.usage.totalTokens self.cost += cost } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index cb1627f..c718646 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -202,6 +202,10 @@ enum TokenStepLocalization { "每日 Token 消耗追踪": "Daily Token Tracking", "Codex 剩余额度": "Codex Quota", "Agent 剩余额度": "Agent Quota", + "今日模型用量": "Today's Model Usage", + "模型用量": "Model Usage", + "暂无模型用量数据": "No model usage data", + "使用支持的 Agent 后这里会显示模型分布。": "Model distribution will appear after using supported agents.", "Codex 额度显示": "Show Codex Quota", "Agent 额度显示": "Show Agent Quota", "生财 Token 榜单": "Shengcai Token Rank", @@ -475,6 +479,10 @@ enum TokenStepLocalization { "每日 Token 消耗追踪": "每日 Token 消耗追蹤", "Codex 剩余额度": "Codex 剩餘額度", "Agent 剩余额度": "Agent 剩餘額度", + "今日模型用量": "今日模型用量", + "模型用量": "模型用量", + "暂无模型用量数据": "暫無模型用量資料", + "使用支持的 Agent 后这里会显示模型分布。": "使用支援的 Agent 後這裡會顯示模型分佈。", "Codex 额度显示": "顯示 Codex 額度", "Agent 额度显示": "顯示 Agent 額度", "生财 Token 榜单": "生財 Token 榜單", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift index a91d6fa..9ff28ec 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift @@ -627,6 +627,16 @@ func tokenToolColor(_ tool: String) -> Color { return .tokenGreen case "Claude Code": return Color(red: 0.88, green: 0.42, blue: 0.24) + case "Kimi Code": + return Color(red: 0.49, green: 0.23, blue: 0.93) + case "Kimi Code CLI": + return Color(red: 0.60, green: 0.35, blue: 0.95) + case "ZCode": + return Color(red: 0.06, green: 0.73, blue: 0.51) + case "Pi": + return Color(red: 0.96, green: 0.62, blue: 0.04) + case "Reasonix": + return Color(red: 0.93, green: 0.29, blue: 0.60) case "Hermes", "Hermes Agent": return Color(red: 0.50, green: 0.28, blue: 0.92) default: @@ -635,7 +645,7 @@ func tokenToolColor(_ tool: String) -> Color { } func orderedToolEntries(_ tools: [String: Int]) -> [(name: String, tokens: Int)] { - let preferred = ["Codex", "Claude Code", "Hermes", "Hermes Agent"] + let preferred = ["Codex", "Claude Code", "Kimi Code", "Kimi Code CLI", "ZCode", "Pi", "Reasonix", "Hermes", "Hermes Agent"] var entries: [(name: String, tokens: Int)] = preferred.compactMap { name in guard let value = tools[name], value > 0 else { return nil } return (name, value) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/StatsView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/StatsView.swift index b31c977..0bc289e 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/StatsView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/StatsView.swift @@ -17,13 +17,29 @@ struct StatsView: View { usageList(title: L("按客户端"), subtitle: L("累计总量分布"), rows: appState.snapshot.tools.map { UsageStatRow(name: $0.tool, value: $0.tokens, percent: $0.percentValue, color: $0.displayColor) }) - usageList(title: L("按模型"), subtitle: "Top \(min(appState.snapshot.models.count, 10)) / \(appState.snapshot.models.count)", rows: appState.snapshot.models.prefix(10).map { - UsageStatRow(name: $0.model, value: $0.tokens, percent: $0.percentValue, color: $0.displayColor) - }) + usageList(title: L("按模型"), subtitle: "Top \(min(modelRows.count, 10)) / \(modelRows.count)", rows: modelRows) } } } + private var modelRows: [UsageStatRow] { + let merged = appState.snapshot.models.reduce(into: [:]) { counts, usage in + counts[usage.model, default: 0] += usage.tokens + } + let total = max(appState.snapshot.totals.tokens, 1) + return merged + .sorted { $0.value > $1.value } + .prefix(10) + .map { name, tokens in + UsageStatRow( + name: name, + value: tokens, + percent: Double(tokens) * 100.0 / Double(total), + color: .tokenGreen + ) + } + } + private var recentActivityCard: some View { TokenCard { VStack(alignment: .leading, spacing: 18) { diff --git a/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift index e4cf4dc..aa7f61b 100644 --- a/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift +++ b/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift @@ -28,7 +28,7 @@ struct CCSwitchProxyFixtureCheck { try assertEqual(snapshot.daily.first?.models["claude-priced"], 155, "priced model tokens") try assertNil(snapshot.daily.first?.models["claude-session-priced"], "session model tokens") try assertNil(snapshot.daily.first?.models["codex-session-priced"], "codex session model tokens") - try assertEqual(snapshot.daily.first?.models["gpt-5.4"], 13, "model fallback tokens") + try assertEqual(snapshot.daily.first?.models["GPT-5.4"], 13, "model fallback tokens") let emptyDatabase = try makeFixtureDatabase(rowsSQL: """ insert into proxy_request_logs ( diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCCSwitchTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCCSwitchTests.swift index dbd02ed..72fc55a 100644 --- a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCCSwitchTests.swift +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCCSwitchTests.swift @@ -32,7 +32,7 @@ final class UsageCollectorCCSwitchTests: XCTestCase { XCTAssertEqual(snapshot.daily.first?.models["claude-priced"], 155) XCTAssertNil(snapshot.daily.first?.models["claude-session-priced"]) XCTAssertNil(snapshot.daily.first?.models["codex-session-priced"]) - XCTAssertEqual(snapshot.daily.first?.models["gpt-5.4"], 13) + XCTAssertEqual(snapshot.daily.first?.models["GPT-5.4"], 13) let tools = Dictionary(uniqueKeysWithValues: snapshot.tools.map { ($0.tool, $0.tokens) }) XCTAssertEqual(tools["Claude Code via CC Switch"], 155) @@ -42,7 +42,7 @@ final class UsageCollectorCCSwitchTests: XCTestCase { XCTAssertEqual(models["Claude Code via CC Switch|claude-priced"], 155) XCTAssertNil(models["Claude Code via CC Switch|claude-session-priced"]) XCTAssertNil(models["Codex via CC Switch|codex-session-priced"]) - XCTAssertEqual(models["Codex via CC Switch|gpt-5.4"], 13) + XCTAssertEqual(models["Codex via CC Switch|GPT-5.4"], 13) } func testValidDatabaseWithoutSuccessfulTokenRowsReportsMissingValidRows() throws { diff --git a/config/pricing.json b/config/pricing.json index 1045cc3..ded653e 100644 --- a/config/pricing.json +++ b/config/pricing.json @@ -7,6 +7,18 @@ }, "Claude Code": { "total_usd_per_1m": 3.0 + }, + "Kimi Code": { + "total_usd_per_1m": 0.8 + }, + "ZCode": { + "total_usd_per_1m": 0.5 + }, + "Pi": { + "total_usd_per_1m": 0.5 + }, + "Reasonix": { + "total_usd_per_1m": 0.5 } }, "models": { @@ -39,6 +51,18 @@ }, "minimax": { "total_usd_per_1m": 0.2 + }, + "GLM-5.2": { + "total_usd_per_1m": 0.5 + }, + "deepseek-v4-pro": { + "total_usd_per_1m": 0.5 + }, + "deepseek-v4-flash": { + "total_usd_per_1m": 0.1 + }, + "daimon-kimi-code": { + "total_usd_per_1m": 0.8 } } } diff --git a/docs/AGENT_SUPPORT.md b/docs/AGENT_SUPPORT.md index 767796d..cf87e64 100644 --- a/docs/AGENT_SUPPORT.md +++ b/docs/AGENT_SUPPORT.md @@ -8,6 +8,10 @@ TokenStep 的原则是:能从本地日志中稳定读到 token 数,才进入 | --- | --- | --- | --- | | Codex | 已支持 | `~/.codex/sessions` / `~/.codex/archived_sessions`,必要时回退 SQLite | 读取本地 token_count 事件,只统计数量;可选读取 5h / 7d 额度。 | | Claude Code | 已支持 | `~/.claude/projects` | 读取 assistant message 的 usage 字段,按 `message.id` 去重,避免 thinking / text / tool_use 多行重复累计;可选通过 Claude Code 本机钥匙串凭证读取 usage 额度。 | +| Kimi Code | 已支持 | `~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home/sessions/**/agents/main/wire.jsonl` | 读取 `usage.record` 事件,字段映射为 `inputOther`/`output`/`inputCacheRead`/`inputCacheCreation`。 | +| ZCode / GLM | 已支持 | `~/.zcode/cli/db/db.sqlite` → `model_usage` | 只读 SQLite `model_usage` 表的 token 数值列,不读 `message`/`part` 正文。 | +| Pi / SPI | 已支持 | `~/.pi/agent/sessions/**/*.jsonl` | 读取 `message` 事件中 assistant 角色的 `usage` 字段。 | +| Reasonix / DeepSeek | 已支持 | `~/.reasonix/sessions/*.events.jsonl` / `~/.reasonix/projects/**/*.events.jsonl` | 读取 `model.final` 事件的 `usage` 字段,并与 `model.turn.started` 关联模型名。 | ## 实验支持:CC Switch Proxy diff --git a/token_usage_monitor.py b/token_usage_monitor.py index d5d41b2..2f966ba 100755 --- a/token_usage_monitor.py +++ b/token_usage_monitor.py @@ -31,6 +31,11 @@ TOOL_COLORS = { "Codex": "#2563eb", "Claude Code": "#df7656", + "Kimi Code": "#7c3aed", + "Kimi Code CLI": "#9959f2", + "ZCode": "#10b981", + "Pi": "#f59e0b", + "Reasonix": "#ec4899", } @@ -94,10 +99,19 @@ def normalize_usage(raw: dict[str, Any] | None) -> dict[str, int]: return usage aliases = { "input": "input_tokens", + "inputOther": "input_tokens", "output": "output_tokens", "cached": "cache_read_input_tokens", + "cacheRead": "cache_read_input_tokens", + "inputCacheRead": "cache_read_input_tokens", + "prompt_cache_hit_tokens": "cache_read_input_tokens", + "cacheWrite": "cache_creation_input_tokens", + "inputCacheCreation": "cache_creation_input_tokens", + "prompt_cache_miss_tokens": "cache_creation_input_tokens", "thoughts": "reasoning_output_tokens", + "reasoning_tokens": "reasoning_output_tokens", "total": "total_tokens", + "totalTokens": "total_tokens", "input_tokens": "input_tokens", "output_tokens": "output_tokens", "cache_creation_input_tokens": "cache_creation_input_tokens", @@ -177,7 +191,6 @@ def collect_codex() -> tuple[list[dict[str, Any]], dict[str, Any]]: home = Path.home() for pattern in [ str(home / ".codex" / "sessions" / "**" / "*.jsonl"), - str(home / ".codex" / "archived_sessions" / "*.jsonl"), ]: paths.extend(glob.glob(pattern, recursive=True)) @@ -329,6 +342,309 @@ def collect_claude_code() -> tuple[list[dict[str, Any]], dict[str, Any]]: return records, {"status": "ok" if records else "missing", "files": files_read, "records": len(records)} +def collect_kimi_code() -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Read Kimi Code wire logs from both the Desktop agent and the CLI.""" + home = Path.home() + roots = [ + ( + "Kimi Code", + str( + home + / "Library" + / "Application Support" + / "kimi-desktop" + / "daimon-share" + / "daimon" + / "runtime" + / "kimi-code" + / "home" + / "sessions" + / "**" + / "agents" + / "main" + / "wire.jsonl" + ), + ), + ( + "Kimi Code CLI", + str(home / ".kimi-code" / "sessions" / "**" / "agents" / "main" / "wire.jsonl"), + ), + ] + + model_aliases = { + "daimon-kimi-code": "Kimi 2.6", + "kimi-code/kimi-for-coding": "Kimi for coding", + "kimi-for-coding": "Kimi for coding", + } + + records: list[dict[str, Any]] = [] + seen: set[tuple[str, ...]] = set() + files_read = 0 + + for tool, pattern in roots: + for path in sorted(glob.glob(pattern, recursive=True)): + session_id = Path(path).parent.parent.parent.name + files_read += 1 + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + for line_no, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + if obj.get("type") != "usage.record": + continue + usage_raw = obj.get("usage") + if not isinstance(usage_raw, dict): + continue + usage = normalize_usage(usage_raw) + if usage["total_tokens"] <= 0: + continue + ts_ms = obj.get("time") + day = date_from_epoch(int(ts_ms) / 1000 if isinstance(ts_ms, (int, float)) else None) + if not day: + continue + raw_model = model_key(obj.get("model") or "daimon-kimi-code") + model = model_aliases.get(raw_model, raw_model) + dedupe_key = (tool, session_id, str(ts_ms), line_no, usage["total_tokens"]) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + records.append( + { + "date": day, + "timestamp": ts_ms, + "tool": tool, + "model": model, + "usage": usage, + "source": "kimi-wire-jsonl", + } + ) + except Exception: + continue + + return records, {"status": "ok" if records else "missing", "files": files_read, "records": len(records)} + + +def collect_zcode() -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Read ZCode (GLM) local SQLite usage statistics. + + Database path: ~/.zcode/cli/db/db.sqlite + """ + db_path = Path.home() / ".zcode" / "cli" / "db" / "db.sqlite" + if not db_path.exists(): + return [], {"status": "missing", "files": 0, "records": 0} + + records: list[dict[str, Any]] = [] + rows_read = 0 + try: + con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + cur = con.cursor() + for row in cur.execute( + """ + SELECT + id, + session_id, + turn_id, + model_id, + provider_id, + started_at, + completed_at, + input_tokens, + output_tokens, + reasoning_tokens, + cache_creation_input_tokens, + cache_read_input_tokens, + computed_total_tokens + FROM model_usage + WHERE status = 'completed' + AND computed_total_tokens > 0 + """ + ): + ( + req_id, + session_id, + turn_id, + model_id, + provider_id, + started_at, + completed_at, + input_tokens, + output_tokens, + reasoning_tokens, + cache_creation_input_tokens, + cache_read_input_tokens, + computed_total_tokens, + ) = row + rows_read += 1 + + ts_ms = completed_at or started_at + day = date_from_epoch(int(ts_ms) / 1000 if ts_ms else None) + if not day: + continue + + usage = empty_usage() + usage["input_tokens"] = int(input_tokens or 0) + usage["output_tokens"] = int(output_tokens or 0) + usage["reasoning_output_tokens"] = int(reasoning_tokens or 0) + usage["cache_creation_input_tokens"] = int(cache_creation_input_tokens or 0) + usage["cache_read_input_tokens"] = int(cache_read_input_tokens or 0) + usage["total_tokens"] = int(computed_total_tokens or 0) + + model = model_key(model_id or "unknown") + records.append( + { + "date": day, + "timestamp": ts_ms, + "tool": "ZCode", + "model": model, + "usage": usage, + "source": "zcode-model-usage", + } + ) + except Exception: + return [], {"status": "error", "files": 1, "records": 0} + + return records, {"status": "ok" if records else "missing", "files": 1, "records": len(records)} + + +def collect_pi() -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Read Pi (SPI) session JSONL logs. + + Path pattern: ~/.pi/agent/sessions/**/*.jsonl + """ + home = Path.home() + pattern = str(home / ".pi" / "agent" / "sessions" / "**" / "*.jsonl") + paths = glob.glob(pattern, recursive=True) + + records: list[dict[str, Any]] = [] + seen: set[tuple[str, ...]] = set() + files_read = 0 + + for path in sorted(paths): + files_read += 1 + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + for line_no, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + if obj.get("type") != "message": + continue + message = obj.get("message", {}) + if message.get("role") != "assistant": + continue + usage_raw = message.get("usage") + if not isinstance(usage_raw, dict): + continue + usage = normalize_usage(usage_raw) + if usage["total_tokens"] <= 0: + continue + day = date_from_iso(obj.get("timestamp")) + if not day: + continue + model = model_key(message.get("model") or "unknown") + dedupe_key = (path, line_no, usage["total_tokens"]) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + records.append( + { + "date": day, + "timestamp": obj.get("timestamp"), + "tool": "Pi", + "model": model, + "usage": usage, + "source": "pi-session-jsonl", + } + ) + except Exception: + continue + + return records, {"status": "ok" if records else "missing", "files": files_read, "records": len(records)} + + +def collect_reasonix() -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Read Reasonix events JSONL logs. + + Path patterns: + ~/.reasonix/sessions/*.events.jsonl + ~/.reasonix/projects/**/*.events.jsonl + """ + home = Path.home() + patterns = [ + str(home / ".reasonix" / "sessions" / "*.events.jsonl"), + str(home / ".reasonix" / "projects" / "**" / "*.events.jsonl"), + ] + paths: list[str] = [] + for pattern in patterns: + paths.extend(glob.glob(pattern, recursive=True)) + + records: list[dict[str, Any]] = [] + seen: set[tuple[str, ...]] = set() + files_read = 0 + + for path in sorted(set(paths)): + files_read += 1 + turn_model: dict[Any, str] = {} + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + for line_no, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + + if obj.get("type") == "model.turn.started": + turn = obj.get("turn") + model = obj.get("model") + if turn is not None and model: + turn_model[turn] = model + + if obj.get("type") != "model.final": + continue + usage_raw = obj.get("usage") + if not isinstance(usage_raw, dict): + continue + usage = normalize_usage(usage_raw) + if usage["total_tokens"] <= 0: + continue + ts = obj.get("ts") + day = date_from_iso(ts) + if not day: + continue + turn = obj.get("turn") + model = model_key(turn_model.get(turn, obj.get("model", "unknown"))) + dedupe_key = (path, str(turn), str(ts), usage["total_tokens"]) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + records.append( + { + "date": day, + "timestamp": ts, + "tool": "Reasonix", + "model": model, + "usage": usage, + "source": "reasonix-events-jsonl", + } + ) + except Exception: + continue + + return records, {"status": "ok" if records else "missing", "files": files_read, "records": len(records)} + + def aggregate(records: list[dict[str, Any]], pricing: dict[str, Any]) -> dict[str, Any]: daily_map: dict[str, dict[str, Any]] = defaultdict(lambda: {"date": "", "tools": {}, "total_tokens": 0, "cost": 0.0}) tool_map: dict[str, dict[str, Any]] = defaultdict(lambda: {"usage": empty_usage(), "cost": 0.0}) @@ -415,11 +731,19 @@ def collect_all() -> dict[str, Any]: pricing = load_pricing() codex_records, codex_meta = collect_codex() claude_records, claude_meta = collect_claude_code() - records = codex_records + claude_records + kimi_records, kimi_meta = collect_kimi_code() + zcode_records, zcode_meta = collect_zcode() + pi_records, pi_meta = collect_pi() + reasonix_records, reasonix_meta = collect_reasonix() + records = codex_records + claude_records + kimi_records + zcode_records + pi_records + reasonix_records result = aggregate(records, pricing) result["sources"] = { "Codex": codex_meta, "Claude Code": claude_meta, + "Kimi Code": kimi_meta, + "ZCode": zcode_meta, + "Pi": pi_meta, + "Reasonix": reasonix_meta, } return result @@ -441,6 +765,18 @@ def json_for_html(data: dict[str, Any]) -> str: def render_dashboard(data: dict[str, Any]) -> str: inline_data = json_for_html(data) generated_at = html.escape(data.get("generated_at", "")) + tools = data.get("tools", []) + css_vars = "\n".join( + f" --{row['tool'].lower().replace(' ', '-')}:{html.escape(row['color'])};" + for row in tools + ) + legend_html = "\n".join( + f" {html.escape(row['tool'])}" + for row in tools + ) + daily_headers = "\n".join(f" {html.escape(row['tool'])}" for row in tools) + js_colors = json.dumps({row["tool"]: row["color"] for row in tools}, ensure_ascii=False) + js_tools = json.dumps([row["tool"] for row in tools], ensure_ascii=False) return f""" @@ -454,8 +790,7 @@ def render_dashboard(data: dict[str, Any]) -> str: --soft: #f3f4f6; --line: #e5e7eb; --panel: rgba(255, 255, 255, 0.96); - --codex: #2563eb; - --claude: #df7656; +{css_vars} }} * {{ box-sizing: border-box; }} html, body {{ max-width: 100%; overflow-x: hidden; }} @@ -727,8 +1062,7 @@ def render_dashboard(data: dict[str, Any]) -> str:

每天用量

- Codex - Claude Code +{legend_html}
@@ -752,7 +1086,9 @@ def render_dashboard(data: dict[str, Any]) -> str: - + +{daily_headers} + @@ -769,8 +1105,8 @@ def render_dashboard(data: dict[str, Any]) -> str:
日期CodexClaude Code合计预估成本日期合计预估成本