diff --git a/docs/providers/kimicode.md b/docs/providers/kimicode.md index 80300883..a92356bb 100644 --- a/docs/providers/kimicode.md +++ b/docs/providers/kimicode.md @@ -8,14 +8,23 @@ MoonshotAI Kimi Code local session usage and tool activity. ## Where it reads from -The provider reads `~/.kimi-code` by default and honors the Kimi Code CLI's `KIMI_CODE_HOME` environment variable. It scans: +By default the provider scans every known Kimi Code runtime store: ```text -$KIMI_CODE_HOME/sessions/wd_*/session_*/ +~/.kimi-code +~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home (Kimi desktop / IDE embedded runtime) +``` + +Setting `KIMI_CODE_HOME` (or passing a home override) narrows the scan to that single home. Inside each home it scans: + +```text +$HOME/sessions/wd_*// ├── state.json └── agents//wire.jsonl ``` +Session directory naming depends on the host product: the CLI uses `session_*`, embedded runtimes use `conv-*` / `ctitle-*`. Any directory is accepted; the `agents/*/wire.jsonl` probe gates real sessions. + Every agent wire is a cache source. Main-agent and subagent calls share the session ID from the `session_*` directory. `state.json.workDir` supplies the project name and path. `probeRoots()` reports the resolved Kimi Code home for `codeburn doctor` even when there are no sessions. ## Storage format diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index fb3edbb9..36171f07 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -138,11 +138,19 @@ final class AppStore { var codexError: String? var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped + var kimiUsage: KimiUsage? + var kimiError: String? + // No keychain dance for Kimi — "connected" just means the CLI's + // credential file exists, so we start dormant and auto-activate on the + // first refresh tick. + var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped + /// Generation tokens for the in-flight refresh tasks. Incremented on every /// disconnect / reset so a fetch that started before the disconnect cannot /// resume after the await and re-populate the freshly-cleared state. private var claudeRefreshGen: Int = 0 private var codexRefreshGen: Int = 0 + private var kimiRefreshGen: Int = 0 private var cache: [PayloadCacheKey: CachedPayload] = [:] private var cacheDate: String = "" @@ -1104,6 +1112,90 @@ final class AppStore { } } + // MARK: - Kimi Code + + /// Unlike Claude/Codex there is no keychain bootstrap: reading the CLI's + /// credential file is prompt-free, so the first refresh tick activates + /// the dormant state automatically. + func bootstrapKimi() async { + // Capture the generation before the await so a disconnect that lands + // mid-fetch cannot be resurrected into .loaded when the fetch returns. + let gen = kimiRefreshGen + kimiLoadState = .bootstrapping + do { + let usage = try await KimiSubscriptionService.refresh() + guard gen == kimiRefreshGen else { return } + kimiUsage = usage + kimiError = nil + kimiLoadState = .loaded + } catch let err as KimiSubscriptionService.FetchError { + guard gen == kimiRefreshGen else { return } + applyKimiFetchError(err) + } catch { + guard gen == kimiRefreshGen else { return } + kimiError = sanitizeForUI(String(describing: error)) + kimiLoadState = .failed + } + } + + func refreshKimi() async { + _ = await refreshKimiReportingSuccess() + } + + @discardableResult + func refreshKimiReportingSuccess() async -> Bool { + if case .dormant = kimiLoadState { + await bootstrapKimi() + return kimiLoadState == .loaded + } + guard KimiSubscriptionService.hasCredential else { + if kimiLoadState != .notBootstrapped { kimiLoadState = .notBootstrapped } + return false + } + let gen = kimiRefreshGen + if kimiUsage == nil { kimiLoadState = .loading } + do { + let usage = try await KimiSubscriptionService.refresh() + guard gen == kimiRefreshGen else { return false } + kimiUsage = usage + kimiError = nil + kimiLoadState = .loaded + return true + } catch let err as KimiSubscriptionService.FetchError { + guard gen == kimiRefreshGen else { return false } + applyKimiFetchError(err) + return false + } catch { + guard gen == kimiRefreshGen else { return false } + kimiError = sanitizeForUI(String(describing: error)) + kimiLoadState = .failed + return false + } + } + + func disconnectKimi() { + KimiSubscriptionService.disconnect() + kimiRefreshGen &+= 1 + kimiUsage = nil + kimiError = nil + kimiLoadState = .notBootstrapped + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + private func applyKimiFetchError(_ err: KimiSubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + kimiError = sanitized + if case .noCredentials = err { + kimiLoadState = .noCredentials + } else if err.isTerminal { + kimiLoadState = .terminalFailure(reason: sanitized) + } else if let retryAt = err.rateLimitRetryAt { + kimiLoadState = .transientFailure(retryAt: retryAt) + } else { + kimiLoadState = .failed + } + } + private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) { let sanitized = sanitizeForUI(err.errorDescription) subscriptionError = sanitized @@ -1172,6 +1264,10 @@ final class AppStore { let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0) if worst > 0 { providers.append(("Codex", worst)) } } + if let usage = kimiUsage, shouldIncludeCachedQuota(loadState: kimiLoadState) { + let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0) + if worst > 0 { providers.append(("Kimi Code", worst)) } + } let worst = providers.map(\.percent).max() ?? 0 let severity = QuotaSummary.severity(for: worst / 100) let sorted = providers.sorted { $0.percent > $1.percent } @@ -1192,6 +1288,7 @@ final class AppStore { switch filter { case .claude: return claudeQuotaSummary(filter: filter) case .codex: return codexQuotaSummary(filter: filter) + case .kimiCode: return kimiQuotaSummary(filter: filter) default: return nil } } @@ -1297,6 +1394,39 @@ final class AppStore { return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines) } + private func kimiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = kimiLoadState { return nil } + if case .bootstrapping = kimiLoadState { return nil } + if case .noCredentials = kimiLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch kimiLoadState { + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected + case .loading: return kimiUsage == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return kimiUsage == nil ? .loading : .stale + case let .terminalFailure(reason): return .terminalFailure(reason: reason) + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = kimiUsage { + if let w = usage.primary { + let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + primary = row + details.append(row) + } + for w in usage.details { + let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + if primary == nil { primary = row } + details.append(row) + } + } + return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: []) + } + /// Persist one snapshot per window so we can answer "what did the prior cycle end at?" /// when the current window has just reset and projection from current data isn't meaningful. /// Also computes the effective_tokens consumed inside each 7-day window from local history, @@ -1400,6 +1530,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case ibmBob = "IBM Bob" case kiro = "Kiro" case kimi = "Kimi" + case kimiCode = "Kimi Code" case lingtaiTui = "LingTai TUI" case kiloCode = "KiloCode" case openclaw = "OpenClaw" @@ -1432,6 +1563,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .grok: ["grok", "grok build"] case .hermes: ["hermes", "hermes agent"] case .lingtaiTui: ["lingtai-tui", "lingtai tui"] + case .kimiCode: ["kimicode", "kimi code"] default: [rawValue.lowercased()] } } @@ -1453,6 +1585,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .kiloCode: "kilo-code" case .kiro: "kiro" case .kimi: "kimi" + case .kimiCode: "kimicode" case .lingtaiTui: "lingtai-tui" case .openclaw: "openclaw" case .opencode: "opencode" diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index fefdffac..489af5ff 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -519,6 +519,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { fileprivate var lastSubscriptionRefreshAt: Date? fileprivate var lastCodexRefreshAt: Date? + fileprivate var lastKimiRefreshAt: Date? private var claudeQuotaFailureCount = 0 private var nextClaudeQuotaRefreshAt: Date? @@ -614,8 +615,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { if let task = codexQuotaRefreshTask { return await task.value } - let task = Task { [store] in - await store.refreshCodexReportingSuccess() + // Kimi Code rides the same tick, but with its own cadence anchor: + // when Codex is not connected its refresh returns false immediately, + // so lastCodexRefreshAt never advances — anchoring Kimi on it would + // poll api.kimi.com on every payload tick instead of the configured + // quota cadence. Anchor on attempt (not success) so a failing Kimi + // endpoint also respects the cadence. + let kimiDue: Bool = { + let cadence = SubscriptionRefreshCadence.current + guard cadence != .manual else { return false } + return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) + }() + if kimiDue { lastKimiRefreshAt = Date() } + let task = Task { [store, kimiDue] in + async let codex = store.refreshCodexReportingSuccess() + if kimiDue { + _ = await store.refreshKimiReportingSuccess() + } + return await codex } codexQuotaRefreshTask = task let result = await task.value @@ -833,6 +850,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { func resetSubscriptionCadenceAnchor() { lastSubscriptionRefreshAt = nil lastCodexRefreshAt = nil + lastKimiRefreshAt = nil claudeQuotaFailureCount = 0 nextClaudeQuotaRefreshAt = nil } diff --git a/mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift new file mode 100644 index 00000000..1925c339 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift @@ -0,0 +1,372 @@ +import Foundation + +/// Live quota snapshot for a Kimi Code subscription, returned by +/// GET https://api.kimi.com/coding/v1/usages. Shape mirrors what +/// steipete/CodexBar consumes: a top-level `usage` envelope plus a +/// `limits` array of additional rate-limit windows. Numeric fields are +/// decoded leniently (String or Int/Double) because the API has shipped +/// both. +struct KimiUsage: Sendable, Equatable { + struct Window: Sendable, Equatable { + let label: String + let limit: Double + let used: Double + let remaining: Double? + let resetsAt: Date? + + var usedPercent: Double { // 0.0 ... 100.0 + guard limit > 0 else { return 0 } + return min(100, max(0, used / limit * 100)) + } + } + + /// The top-level `usage` envelope — treated as the primary window. + let primary: Window? + /// Additional windows from the `limits` array (e.g. 5-hour rate limit). + let details: [Window] + /// Membership tier from user.membership.level (e.g. "Intermediate"). + let plan: String? + /// Max parallel sessions from parallel.limit, when reported. + let parallelLimit: Int? + let fetchedAt: Date +} + +/// Mirror of CodexSubscriptionService for Kimi Code. Reads the CLI's +/// credential file directly (~/.kimi-code/credentials/kimi-code.json) — +/// no keychain bootstrap, no OAuth refresh. Tokens are short-lived +/// (~15 min) and only the Kimi CLI refreshes them, so an expired token is +/// a terminal state: the UI tells the user to run the CLI once. +enum KimiSubscriptionService { + private static let usageURL = URL(string: "https://api.kimi.com/coding/v1/usages")! + private static let usageBlockedUntilKey = "codeburn.kimi.usage.blockedUntil" + + enum FetchError: Error, LocalizedError { + case noCredentials + case tokenExpired + case rateLimited(retryAt: Date) + case usageHTTPError(Int, String?) + case usageDecodeFailed + case network(Error) + + var errorDescription: String? { + switch self { + case .noCredentials: + return "No Kimi Code credentials found. Sign in with the Kimi CLI first." + case .tokenExpired: + return "Kimi Code login expired. Run the Kimi CLI once to refresh, then try again." + case let .rateLimited(retryAt): + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return "Kimi rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))." + case let .usageHTTPError(code, body): + return "Kimi quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")" + case .usageDecodeFailed: return "Kimi quota response was malformed." + case let .network(err): return "Network error: \(err.localizedDescription)" + } + } + + var isTerminal: Bool { + if case .tokenExpired = self { return true } + if case .noCredentials = self { return true } + return false + } + + var rateLimitRetryAt: Date? { + if case let .rateLimited(retryAt) = self { return retryAt } + return nil + } + } + + // MARK: - Credential file + + private struct CredentialFile: Decodable { + let accessToken: String + let expiresAt: Double + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case expiresAt = "expires_at" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + accessToken = try c.decode(String.self, forKey: .accessToken) + // Tolerate Int / Double / String epoch-seconds. + if let n = try? c.decode(Double.self, forKey: .expiresAt) { + expiresAt = n + } else if let s = try? c.decode(String.self, forKey: .expiresAt), let n = Double(s) { + expiresAt = n + } else { + throw DecodingError.dataCorruptedError(forKey: .expiresAt, in: c, debugDescription: "expires_at missing or not numeric") + } + } + } + + private static var credentialsURL: URL { + let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"] + ?? NSHomeDirectory() + "/.kimi-code" + return URL(fileURLWithPath: home + "/credentials/kimi-code.json") + } + + static var hasCredential: Bool { + FileManager.default.fileExists(atPath: credentialsURL.path) + } + + /// Returns the access token only when it is still fresh (60s skew). + /// Throws noCredentials / tokenExpired otherwise. + private static func freshToken() throws -> String { + guard let data = FileManager.default.contents(atPath: credentialsURL.path), + let cred = try? JSONDecoder().decode(CredentialFile.self, from: data), + !cred.accessToken.isEmpty else { + throw FetchError.noCredentials + } + guard cred.expiresAt > Date().timeIntervalSince1970 + 60 else { + throw FetchError.tokenExpired + } + return cred.accessToken + } + + private static func deviceId() -> String? { + let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"] + ?? NSHomeDirectory() + "/.kimi-code" + guard let data = FileManager.default.contents(atPath: home + "/device_id"), + let id = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !id.isEmpty else { return nil } + return id + } + + // MARK: - Fetch + + static func refresh() async throws -> KimiUsage { + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + let token = try freshToken() + + var request = URLRequest(url: usageURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent") + // Kimi server expects these platform headers (same as CodexBar sends). + request.setValue("kimi_code_cli", forHTTPHeaderField: "X-Msh-Platform") + if let deviceId = deviceId() { + request.setValue(deviceId, forHTTPHeaderField: "X-Msh-Device-Id") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw FetchError.network(error) + } + guard let http = response as? HTTPURLResponse else { + throw FetchError.usageHTTPError(-1, nil) + } + + switch http.statusCode { + case 200: + clearUsageBlock() + do { + return try parseUsage(data: data) + } catch { + // Never log the body — account data readable via `log stream`. + NSLog("CodeBurn: kimi usage decode failed: %@", String(describing: error)) + throw FetchError.usageDecodeFailed + } + case 401, 403: + // We don't self-refresh; surface as terminal so the UI prompts + // the user to run the CLI. + throw FetchError.tokenExpired + case 429: + let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After")) + let until = recordUsageRateLimit(retryAfterSeconds: retryAfter) + throw FetchError.rateLimited(retryAt: until) + default: + throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8)) + } + } + + // MARK: - Decode (internal so tests can drive fixtures) + + private struct LenientDouble: Decodable { + let value: Double? + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let n = try? c.decode(Double.self) { value = n } + else if let s = try? c.decode(String.self) { value = Double(s) } + else { value = nil } + } + } + + private struct UsageEnvelopeDTO: Decodable { + let limit: LenientDouble? + let used: LenientDouble? + let remaining: LenientDouble? + let resetTime: LenientString? + let resetAt: LenientString? + let reset_time: LenientString? + let reset_at: LenientString? + + var resetsAtRaw: String? { + resetTime?.value ?? resetAt?.value ?? reset_time?.value ?? reset_at?.value + } + } + + /// Reset timestamps are normally ISO-8601 strings, but decode numbers + /// tolerantly too (epoch seconds) so a schema drift can't fail the whole + /// response. A plain String? would throw on a JSON number. + private struct LenientString: Decodable { + let value: String? + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let s = try? c.decode(String.self) { value = s } + else if let n = try? c.decode(Double.self) { value = String(n) } + else { value = nil } + } + } + + private struct LimitDTO: Decodable { + let window: WindowDTO? + let detail: UsageEnvelopeDTO? + + struct WindowDTO: Decodable { + let duration: LenientDouble? + let timeUnit: String? + } + } + + private struct ResponseDTO: Decodable { + let usage: UsageEnvelopeDTO? + let limits: [LimitDTO]? + let user: UserDTO? + let parallel: ParallelDTO? + + struct UserDTO: Decodable { + let membership: MembershipDTO? + struct MembershipDTO: Decodable { + let level: String? + } + } + struct ParallelDTO: Decodable { + let limit: LenientDouble? + } + } + + static func parseUsage(data: Data, now: Date = Date()) throws -> KimiUsage { + let root = try JSONDecoder().decode(ResponseDTO.self, from: data) + // The top-level usage envelope is the account's weekly quota (its + // reset lands ~7 days out), so label it like Claude's weekly window. + let primary = makeWindow(label: "Weekly", dto: root.usage) + let details: [KimiUsage.Window] = (root.limits ?? []).compactMap { limit in + guard let dto = limit.detail else { return nil } + let label = windowLabel(duration: limit.window?.duration?.value, timeUnit: limit.window?.timeUnit) + return makeWindow(label: label, dto: dto) + } + guard primary != nil || !details.isEmpty else { + throw FetchError.usageDecodeFailed + } + return KimiUsage( + primary: primary, + details: details, + plan: planName(from: root.user?.membership?.level), + parallelLimit: root.parallel?.limit?.value.map { Int($0) }, + fetchedAt: now + ) + } + + /// "LEVEL_INTERMEDIATE" → "Intermediate". Unknown / missing → nil so the + /// UI falls back to the plain "Kimi Code" label. + private static func planName(from level: String?) -> String? { + guard var raw = level, !raw.isEmpty else { return nil } + if raw.hasPrefix("LEVEL_") { raw = String(raw.dropFirst("LEVEL_".count)) } + return raw.replacingOccurrences(of: "_", with: " ").capitalized + } + + private static func makeWindow(label: String, dto: UsageEnvelopeDTO?) -> KimiUsage.Window? { + guard let dto, let limit = dto.limit?.value, limit > 0 else { return nil } + // Rate-limit windows report only limit + remaining — derive used. + let used = dto.used?.value ?? max(0, limit - (dto.remaining?.value ?? limit)) + return KimiUsage.Window( + label: label, + limit: limit, + used: used, + remaining: dto.remaining?.value, + resetsAt: dto.resetsAtRaw.flatMap(parseResetTime) + ) + } + + /// Window size → human label. The API sends enum-style units + /// ("TIME_UNIT_MINUTE", duration 300) as well as plain ones ("hour"), + /// so normalize first; sub-hour durations roll up to hours when exact. + private static func windowLabel(duration: Double?, timeUnit: String?) -> String { + guard let duration, let rawUnit = timeUnit else { return "Rate Limit" } + var unit = rawUnit.lowercased() + if unit.hasPrefix("time_unit_") { unit = String(unit.dropFirst("time_unit_".count)) } + var d = duration + // Roll up exact sub-day durations: 300 minutes → 5 hours. + if unit == "minute" || unit == "minutes", d.truncatingRemainder(dividingBy: 60) == 0, d >= 60 { + d /= 60; unit = "hour" + } + let i = Int(d) + switch unit { + case "minute", "minutes": return i == 1 ? "Minutely" : "\(i)-min" + case "hour", "hours": return i == 1 ? "Hourly" : "\(i)-hour" + case "day", "days": + if i == 1 { return "Daily" } + if i == 7 { return "Weekly" } + return "\(i)-day" + case "week", "weeks": return i == 1 ? "Weekly" : "\(i)-week" + case "month", "months": return i == 1 ? "Monthly" : "\(i)-month" + default: return "\(i) \(unit)" + } + } + + /// resetTime arrives as ISO-8601 (fractional seconds optional) or epoch seconds. + private static func parseResetTime(_ raw: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: raw) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + if let date = plain.date(from: raw) { return date } + if let epoch = Double(raw) { return Date(timeIntervalSince1970: epoch) } + return nil + } + + // MARK: - 429 backoff + + private static func usageBlockedUntil() -> Date? { + UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date + } + + private static func clearUsageBlock() { + UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey) + } + + private static func parseRetryAfterHeader(_ value: String?) -> Int? { + guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil } + if let seconds = Int(value), seconds >= 0 { return seconds } + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + if let date = f.date(from: value) { + return max(0, Int(date.timeIntervalSinceNow)) + } + return nil + } + + private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date { + let seconds = max(retryAfterSeconds ?? 300, 60) + let until = Date().addingTimeInterval(TimeInterval(seconds)) + UserDefaults.standard.set(until, forKey: usageBlockedUntilKey) + return until + } + + static func disconnect() { + clearUsageBlock() + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 8e346388..40deb714 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -494,6 +494,7 @@ extension ProviderFilter { case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0) case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0) case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0) + case .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0) case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0) case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0) case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0) diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index fbd5871d..8f026761 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -62,7 +62,7 @@ struct HeatmapSection: View { // their own quota data sources. InsightMode.allCases.filter { mode in if mode == .plan { - return store.selectedProvider == .claude || store.selectedProvider == .codex + return store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode } return true } @@ -80,6 +80,8 @@ struct HeatmapSection: View { case .plan: if store.selectedProvider == .codex { CodexPlanInsight() + } else if store.selectedProvider == .kimiCode { + KimiPlanInsight() } else { PlanInsight(usage: store.subscription) } @@ -2158,6 +2160,110 @@ private struct CodexPlanInsight: View { } } +/// Plan tab for Kimi Code. Reads the CLI credential file (no keychain, no +/// OAuth refresh — tokens are short-lived and only the CLI renews them), so +/// terminal failure means "run the CLI once to refresh your login". +private struct KimiPlanInsight: View { + @Environment(AppStore.self) private var store + + var body: some View { + Group { + switch store.kimiLoadState { + case .notBootstrapped, .noCredentials: + PlanNoCredentialsView( + title: "No Kimi Code credentials found", + message: "Sign in with the Kimi CLI first. Then click Try Again." + ) { Task { await store.bootstrapKimi() } } + case .dormant, .bootstrapping: + PlanLoadingView(message: "Reading Kimi Code credentials...") + case .loading: + if let usage = store.kimiUsage { + loadedBody(usage: usage) + } else { + PlanLoadingView(message: "Reading Kimi Code credentials...") + } + case .failed: + PlanFailedView( + error: store.kimiError + ) { Task { await store.refreshKimi() } } + case .transientFailure: + if let usage = store.kimiUsage { + loadedBody(usage: usage) + } else { + PlanFailedView( + error: store.kimiError ?? "Kimi temporarily unreachable — retrying." + ) { Task { await store.refreshKimi() } } + } + case let .terminalFailure(reason): + PlanReconnectView( + title: "Refresh Kimi Code login", + reason: reason, + fallback: "Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect." + ) { Task { await store.bootstrapKimi() } } + case .loaded: + if let usage = store.kimiUsage { + loadedBody(usage: usage) + } else { + PlanLoadingView(message: "Reading Kimi Code credentials...") + } + } + } + } + + @ViewBuilder + private func loadedBody(usage: KimiUsage) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(usage.plan ?? "Kimi Code") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + if let resetsAt = usage.primary?.resetsAt { + Text("Resets \(relativeReset(resetsAt))") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + if let primary = usage.primary { + UtilizationRow( + label: "\(primary.label) window", + percent: primary.usedPercent, + resetsAt: primary.resetsAt, + projection: nil + ) + } + ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in + UtilizationRow( + label: "\(window.label) window", + percent: window.usedPercent, + resetsAt: window.resetsAt, + projection: nil + ) + } + if let parallel = usage.parallelLimit, parallel > 0 { + HStack(alignment: .firstTextBaseline) { + Text("Parallel sessions") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Text("\(parallel)") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 14) + .padding(.top, 4) + .padding(.bottom, 8) + } + + private func relativeReset(_ date: Date) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return f.localizedString(for: date, relativeTo: Date()) + } +} + private struct WindowProjection { enum Source { case linear, historicalBaseline } let percent: Double diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index d92bd745..18b6a133 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -92,6 +92,10 @@ struct MenuBarContent: View { private var isFilteredEmpty: Bool { guard store.selectedProvider != .all else { return false } + // Plan-capable providers keep their sections visible so the Plan tab + // (live subscription quota) stays reachable even on days with no + // local usage — the quota endpoint doesn't depend on local sessions. + if store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode { return false } if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false } if providerHasCostInAllPayload { return false } return true diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 285822a7..f220ba5e 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -21,6 +21,10 @@ struct SettingsView: View { .tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") } .tag("codex") + KimiSettingsTab() + .tabItem { Label("Kimi", systemImage: "moon.stars") } + .tag("kimi") + DevinSettingsTab() .tabItem { Label("Devin", systemImage: "flame.fill") } .tag("devin") @@ -29,7 +33,9 @@ struct SettingsView: View { .tabItem { Label("About", systemImage: "info.circle") } .tag("about") } - .frame(width: 520, height: 430) + // 6 tabs need ~600pt to render as a visible tab bar; narrower widths + // make SwiftUI collapse the tab bar into a ">>" overflow menu. + .frame(width: 600, height: 430) } } @@ -606,6 +612,131 @@ private struct CodexConnectionRow: View { } } +// MARK: - Kimi Code + +private struct KimiSettingsTab: View { + var body: some View { + Form { + Section("Connection") { + KimiConnectionRow() + } + Section { + Text("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly — nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } header: { + Text("How it works") + } + } + .formStyle(.grouped) + .padding() + } +} + +private struct KimiConnectionRow: View { + @Environment(AppStore.self) private var store + @State private var showDisconnectConfirm = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: stateIcon) + .font(.system(size: 18)) + .foregroundStyle(stateTint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(stateTitle) + .font(.system(size: 12, weight: .semibold)) + Text(stateDetail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + actionButton + } + .padding(.vertical, 4) + } + + private var stateIcon: String { + switch store.kimiLoadState { + case .loaded: return "checkmark.circle.fill" + case .terminalFailure: return "exclamationmark.triangle.fill" + case .transientFailure: return "clock.arrow.circlepath" + case .bootstrapping, .loading: return "ellipsis.circle" + case .notBootstrapped, .dormant, .noCredentials: return "link.circle" + case .failed: return "xmark.circle" + } + } + + private var stateTint: Color { + switch store.kimiLoadState { + case .loaded: return .green + case .terminalFailure, .failed: return .red + case .transientFailure: return .orange + default: return .secondary + } + } + + private var stateTitle: String { + switch store.kimiLoadState { + case .loaded: return "Connected" + case let .terminalFailure(reason): return reason ?? "Login refresh required" + case .transientFailure: return "Backing off" + case .bootstrapping: return "Connecting…" + case .loading: return "Refreshing…" + case .dormant: return "Ready" + case .notBootstrapped, .noCredentials: return "Not connected" + case .failed: return "Couldn't load Kimi quota" + } + } + + private var stateDetail: String { + switch store.kimiLoadState { + case .loaded: + return "Live quota tracked from api.kimi.com." + case .terminalFailure: + return "Run the Kimi CLI once to refresh your login, then click Reconnect." + case .transientFailure: return store.kimiError ?? "Kimi rate-limited; auto-retrying." + case .bootstrapping: return "Reading ~/.kimi-code credentials." + case .loading: return "Background refresh in progress." + case .dormant: return "Tap Load Quota to fetch live usage from api.kimi.com." + case .notBootstrapped, .noCredentials: + return "Sign in with the Kimi CLI first, then click Connect." + case .failed: return store.kimiError ?? "" + } + } + + @ViewBuilder + private var actionButton: some View { + switch store.kimiLoadState { + case .loaded, .transientFailure, .loading: + Button("Disconnect") { showDisconnectConfirm = true } + .confirmationDialog( + "Disconnect Kimi Code?", + isPresented: $showDisconnectConfirm + ) { + Button("Disconnect", role: .destructive) { + store.disconnectKimi() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched — the Kimi CLI keeps working.") + } + case .terminalFailure, .noCredentials, .failed: + Button("Reconnect") { Task { await store.bootstrapKimi() } } + .buttonStyle(.borderedProminent) + case .dormant: + Button("Load Quota") { Task { await store.bootstrapKimi() } } + .buttonStyle(.borderedProminent) + case .notBootstrapped: + Button("Connect") { Task { await store.bootstrapKimi() } } + .buttonStyle(.borderedProminent) + case .bootstrapping: + ProgressView().controlSize(.small) + } + } +} + // MARK: - Devin private struct DevinSettingsTab: View { diff --git a/mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift b/mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift new file mode 100644 index 00000000..2d78c43f --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import CodeBurnMenubar + +/// Fixture-driven decode tests for the Kimi Code /coding/v1/usages response. +/// The API has shipped numbers as both JSON numbers and strings, and the +/// reset timestamp under several key spellings (resetTime / reset_at / ...), +/// so the parser must tolerate all of them. +final class KimiUsageParsingTests: XCTestCase { + + func testParsesNumericShapeWithResetTime() throws { + let json = """ + { + "usage": {"limit": 100, "used": 40, "remaining": 60, "resetTime": "2026-07-30T12:00:00Z"}, + "limits": [ + {"window": {"duration": 5, "timeUnit": "hour"}, + "detail": {"limit": 20, "used": 10, "remaining": 10, "resetTime": "2026-07-23T21:00:00Z"}} + ] + } + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.primary?.limit, 100) + XCTAssertEqual(usage.primary?.used, 40) + XCTAssertEqual(usage.primary?.usedPercent ?? -1, 40, accuracy: 0.001) + XCTAssertEqual(usage.primary?.remaining, 60) + XCTAssertNotNil(usage.primary?.resetsAt) + XCTAssertEqual(usage.details.count, 1) + XCTAssertEqual(usage.details.first?.label, "5-hour") + XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 50, accuracy: 0.001) + } + + func testParsesStringNumbersAndSnakeCaseReset() throws { + let json = """ + { + "usage": {"limit": "500", "used": "123", "remaining": "377", "reset_at": "2026-07-30T12:00:00.000Z"}, + "limits": [] + } + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.primary?.limit, 500) + XCTAssertEqual(usage.primary?.used, 123) + XCTAssertNotNil(usage.primary?.resetsAt) + } + + func testWeeklyWindowLabel() throws { + let json = """ + { + "limits": [ + {"window": {"duration": 7, "timeUnit": "day"}, + "detail": {"limit": 1000, "used": 250}} + ] + } + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertNil(usage.primary) + XCTAssertEqual(usage.details.first?.label, "Weekly") + } + + func testEpochResetTime() throws { + let json = """ + {"usage": {"limit": 10, "used": 5, "resetTime": "1784900000"}} + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000)) + } + + func testNumericEpochResetTime() throws { + // A JSON number (not string) must not fail the whole decode. + let json = """ + {"usage": {"limit": 10, "used": 5, "resetTime": 1784900000}} + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000)) + } + + func testLiveResponseShape() { + // Captured from GET https://api.kimi.com/coding/v1/usages (2026-07-23). + let json = """ + { + "user": {"userId": "x", "region": "REGION_OVERSEA", + "membership": {"level": "LEVEL_INTERMEDIATE"}}, + "usage": {"limit": "100", "used": "5", "remaining": "95", + "resetTime": "2026-07-30T13:27:17.211180Z"}, + "limits": [ + {"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"}, + "detail": {"limit": "100", "remaining": "100", + "resetTime": "2026-07-23T23:27:17.211180Z"}} + ], + "parallel": {"limit": "20"} + } + """.data(using: .utf8)! + let usage = try! KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.plan, "Intermediate") + XCTAssertEqual(usage.parallelLimit, 20) + XCTAssertEqual(usage.primary?.label, "Weekly") + XCTAssertEqual(usage.primary?.usedPercent ?? -1, 5, accuracy: 0.001) + // 300 minutes rolls up to a 5-hour label; used derives from remaining. + XCTAssertEqual(usage.details.count, 1) + XCTAssertEqual(usage.details.first?.label, "5-hour") + XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 0, accuracy: 0.001) + XCTAssertNotNil(usage.details.first?.resetsAt) + } + + func testEmptyEnvelopeThrows() { + let json = "{}".data(using: .utf8)! + XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json)) + } + + func testZeroLimitWindowDropped() throws { + let json = """ + {"usage": {"limit": 0, "used": 0}, "limits": []} + """.data(using: .utf8)! + XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json)) + } +} diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 1d9b6d25..1b68a222 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -83,6 +83,7 @@ const PROVIDER_COLORS: Record = { opencode: '#A78BFA', pi: '#F472B6', kimi: '#B6E34A', + kimicode: '#A3E635', all: '#FF8C42', } @@ -670,6 +671,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { opencode: 'OpenCode', pi: 'Pi', kimi: 'Kimi', + kimicode: 'Kimi Code', } function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 20665725..74354268 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -84,6 +84,7 @@ export type ProviderCost = { import type { OptimizeResult } from './optimize.js' import { getCurrency } from './currency.js' import type { GranularHistory } from './granular-history.js' +import { getShortModelName } from './models.js' import type { ReworkedFile } from './workflow-insights.js' import type { PrRow, BranchRow } from './sessions-report.js' @@ -369,10 +370,24 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa } function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] { - return models - .filter(m => m.name !== SYNTHETIC_MODEL_NAME) + // Day entries key models by the raw provider id (day-aggregator), so resolve + // display names here — the menubar shows "Kimi K3" rather than "k3". Ids that + // collapse to one display name (e.g. k3 and kimi-k3) merge into a single row. + const merged = new Map() + for (const m of models) { + if (m.name === SYNTHETIC_MODEL_NAME) continue + const name = getShortModelName(m.name) + const acc = merged.get(name) ?? { cost: 0, calls: 0, savingsUSD: 0, estimatedCostUSD: 0 } + acc.cost += m.cost + acc.calls += m.calls + acc.savingsUSD += m.savingsUSD ?? 0 + acc.estimatedCostUSD += m.estimatedCostUSD ?? 0 + merged.set(name, acc) + } + return [...merged.entries()] + .sort(([, a], [, b]) => b.cost - a.cost) .slice(0, TOP_MODELS_LIMIT) - .map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 })) + .map(([name, d]) => ({ name, cost: d.cost, calls: d.calls, savingsUSD: d.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: d.estimatedCostUSD })) } function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] { diff --git a/src/models.ts b/src/models.ts index 20d30914..6e05785f 100644 --- a/src/models.ts +++ b/src/models.ts @@ -290,6 +290,12 @@ const BUILTIN_ALIASES: Record = { 'kimi-auto': 'kimi-k2-thinking', 'kimi-code': 'kimi-k2-thinking', 'kimi-for-coding': 'kimi-k2-thinking', + // Kimi Code wires report the bare `k3` id in llm.request.model; without an + // alias those calls priced at $0 and the provider looked absent in the UI. + 'k3': 'kimi-k3', + // Kimi desktop/IDE embedded runtime serves `k3-agent` / `k2d6-agent`. + 'k3-agent': 'kimi-k3', + 'k2d6-agent': 'kimi-k2p6', 'mimo-v2-flash': 'xiaomi/mimo-v2-flash', 'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro', // Cursor emits dot-version tier-last names plus tier/reasoning suffixes @@ -879,6 +885,8 @@ const SHORT_NAMES: Record = { 'gemini-2.5-flash': 'Gemini 2.5 Flash', 'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo', 'kimi-k2-thinking': 'Kimi K2 Thinking', + 'kimi-k3': 'Kimi K3', + 'kimi-k2p6': 'Kimi K2.6', 'kimi-thinking-preview': 'Kimi Thinking', 'kimi-k2.6': 'Kimi K2.6', 'kimi-k2.5': 'Kimi K2.5', diff --git a/src/providers/kimicode.ts b/src/providers/kimicode.ts index e843ae14..b85845a9 100644 --- a/src/providers/kimicode.ts +++ b/src/providers/kimicode.ts @@ -71,8 +71,18 @@ function timestampIso(value: unknown): string { return Number.isNaN(date.getTime()) ? '' : date.toISOString() } -function kimicodeHome(override?: string): string { - return resolve(override || process.env['KIMI_CODE_HOME'] || join(homedir(), '.kimi-code')) +function kimicodeHomes(override?: string): string[] { + const explicit = override || process.env['KIMI_CODE_HOME'] + if (explicit) return [resolve(explicit)] + // Default stores. Beyond the CLI's own ~/.kimi-code, embedded runtimes keep + // the same wire layout under their own home (Kimi desktop app, Kimi Code + // IDE); each home is scanned so embedded-agent usage is not invisible. + const home = homedir() + const homes = [ + join(home, '.kimi-code'), + join(home, 'Library', 'Application Support', 'kimi-desktop', 'daimon-share', 'daimon', 'runtime', 'kimi-code', 'home'), + ] + return [...new Set(homes.map(h => resolve(h)))] } async function directoryEntries(path: string) { @@ -120,7 +130,10 @@ async function discoverSources(root: string): Promise { const workDirPath = join(sessionsDir, workDirEntry.name) for (const sessionEntry of await directoryEntries(workDirPath)) { - if (!sessionEntry.isDirectory() || !sessionEntry.name.startsWith('session_')) continue + // Session dir naming differs by host product: the CLI uses session_*, + // embedded runtimes (desktop app, IDE) use conv-*/ctitle-*. Any directory + // is accepted; the agents/*/wire.jsonl probe below gates real sessions. + if (!sessionEntry.isDirectory()) continue const sessionDir = join(workDirPath, sessionEntry.name) const state = await readState(sessionDir) const project = projectFromWorkDir(state.workDir ?? '', workDirEntry.name) @@ -344,11 +357,15 @@ export function createKimicodeProvider(homeOverride?: string): Provider { }, async probeRoots(): Promise { - return [{ path: kimicodeHome(homeOverride), label: 'Kimi Code home' }] + return kimicodeHomes(homeOverride).map(path => ({ path, label: 'Kimi Code home' })) }, async discoverSessions(): Promise { - return discoverSources(kimicodeHome(homeOverride)) + const all: SessionSource[] = [] + for (const home of kimicodeHomes(homeOverride)) { + all.push(...await discoverSources(home)) + } + return all.sort((a, b) => a.path.localeCompare(b.path)) }, createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index fe9b55ae..cd447417 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -159,6 +159,31 @@ describe('buildMenubarPayload', () => { expect(payload.current.topModels[0].name).toBe('Model0') }) + it('resolves raw model ids to display names in topModels and merges rows that collapse', () => { + // Day entries key models by the raw wire id (day-aggregator); the menubar + // must show friendly names and merge ids that share one (k3 + kimi-k3). + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [], + models: [ + { name: 'k3', cost: 2.5, calls: 78 }, + { name: 'kimi-k3', cost: 0.5, calls: 2 }, + { name: 'kimi-for-coding', cost: 0.06, calls: 13 }, + { name: 'k3-agent', cost: 1.2, calls: 40 }, + ], + } + const payload = buildMenubarPayload(period, [], null) + const kimiK3 = payload.current.topModels.find(m => m.name === 'Kimi K3')! + expect(kimiK3.cost).toBeCloseTo(2.5 + 0.5 + 1.2) + expect(kimiK3.calls).toBe(78 + 2 + 40) + const k2 = payload.current.topModels.find(m => m.name === 'Kimi K2 Thinking')! + expect(k2.cost).toBeCloseTo(0.06) + expect(payload.current.topModels.find(m => m.name === 'k3')).toBeUndefined() + expect(payload.current.topModels.find(m => m.name === 'k3-agent')).toBeUndefined() + }) + it('caps topActivities at 20 so all task categories can surface', () => { const period: PeriodData = { label: 'Today', diff --git a/tests/menubar-savings.test.ts b/tests/menubar-savings.test.ts index ba416abc..bb608977 100644 --- a/tests/menubar-savings.test.ts +++ b/tests/menubar-savings.test.ts @@ -53,7 +53,7 @@ describe('buildMenubarPayload: local-model savings', () => { const local = payload.current.topModels.find(m => m.name === 'Local Model')! expect(local.savingsUSD).toBe(5) expect(local.cost).toBe(0) - const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')! + const paid = payload.current.topModels.find(m => m.name === 'GPT-4o')! expect(paid.savingsUSD).toBe(0) }) diff --git a/tests/models.test.ts b/tests/models.test.ts index 160c5f80..e0d3cfd0 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -672,6 +672,9 @@ describe('observed provider model aliases', () => { const cases: Array<[string, string]> = [ ['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'], ['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'], + // Kimi Code wires report bare `k3` in llm.request.model; it must price + // through the kimi-k3 table entry, not fall through to $0. + ['k3', 'kimi-k3'], ] for (const [input, expectedModel] of cases) { @@ -685,6 +688,10 @@ describe('observed provider model aliases', () => { }) } + it('k3 shows the Kimi K3 display name', () => { + expect(getShortModelName('k3')).toBe('Kimi K3') + }) + it('does not map dated Qwen3 Max to a reseller price without provider context', () => { expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull() expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0) diff --git a/tests/providers/kimicode.test.ts b/tests/providers/kimicode.test.ts index 6bcc089f..2ff426ad 100644 --- a/tests/providers/kimicode.test.ts +++ b/tests/providers/kimicode.test.ts @@ -167,6 +167,40 @@ describe('Kimi Code provider', () => { expect(sources.every(source => source.sourcePath === '/workspace/neutral-project')).toBe(true) }) + it('discovers embedded-runtime conv-* and ctitle-* session directories', async () => { + // Embedded runtimes (Kimi desktop app, Kimi Code IDE) name session dirs + // conv-*/ctitle-* instead of session_*; their wires carry the same event + // format and must be discovered and priced the same way. + for (const dirName of ['conv-abc123def456', 'ctitle-019f8f78-db81']) { + const agentDir = join(fixtureHome, 'sessions', 'wd_neutral-project_0123456789ab', dirName, 'agents', 'main') + await mkdir(agentDir, { recursive: true }) + await writeFile(join(agentDir, '..', 'state.json'), JSON.stringify({ + createdAt: '2026-07-01T10:00:00.000Z', + updatedAt: '2026-07-01T10:05:00.000Z', + workDir: '/workspace/neutral-project', + })) + await writeFile(join(agentDir, 'wire.jsonl'), [ + JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1782900000000 }), + JSON.stringify(prompt('hello from an embedded runtime', 1782900000000)), + JSON.stringify(request('0.1', 'k3-agent', 'k3-agent', 1782900001000)), + JSON.stringify(usage('k3-agent', 1782900002000, { input: 100, output: 50 })), + '', + ].join('\n')) + } + + const provider = createKimicodeProvider(fixtureHome) + const sources = await provider.discoverSessions() + expect(sources).toHaveLength(2) + expect(sources.every(source => source.project === 'neutral-project')).toBe(true) + + const seen = new Set() + const calls = (await Promise.all(sources.map(source => collect(provider, source, seen)))).flat() + expect(calls).toHaveLength(2) + expect(calls.every(call => call.model === 'k3-agent')).toBe(true) + // k3-agent prices through the kimi-k3 alias, never $0. + expect(calls.every(call => call.costUSD > 0)).toBe(true) + }) + it('parses single-turn usage with the real model id and estimated token pricing', async () => { const [wirePath] = await writeSession('single-turn', [{ id: 'main', lines: [ prompt('Summarize the neutral module.', 1782900000000),