mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-31 20:05:15 +00:00
Read ~/.kimi-code credentials directly and poll api.kimi.com /coding/v1/usages for the weekly quota and rate-limit windows, mirroring the existing Codex/Claude quota services. - New KimiSubscriptionService with lenient decoding (string/number values, enum-style time units, derived used from remaining) and 429 backoff - Quota chip on the Kimi Code tab, Plan insight, and a Kimi tab in Settings with connect/disconnect - Independent refresh cadence anchor so Kimi-only setups don't poll on every payload tick - Expired tokens surface as terminal with automatic recovery once the CLI refreshes the credential file - Keep insights visible for quota-capable providers on empty days - Widen Settings so six tabs don't collapse into the overflow menu
372 lines
15 KiB
Swift
372 lines
15 KiB
Swift
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()
|
|
}
|
|
}
|