diff --git a/README.md b/README.md index d0e0474de..86aeb418d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing. - [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API. - [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows. -- [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit. +- [Kimi](docs/kimi.md) — Kimi Code OAuth or API-key usage for weekly quota + rolling rate limits. - [Kimi K2](docs/kimi-k2.md) — API key for credit-based usage totals. - [Kiro](docs/kiro.md) — CLI-based usage via `kiro-cli /usage` command; monthly credits + bonus credits. - [Vertex AI](docs/vertexai.md) — Google Cloud gcloud OAuth with token cost tracking from local Claude logs. diff --git a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift index a34629766..7680ac5f4 100644 --- a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift +++ b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift @@ -104,7 +104,6 @@ struct CodexBarConfigMigrator { state: &state) self.migrateMiniMax(userDefaults: userDefaults, stores: stores, config: &config, state: &state) - self.migrateKimi(userDefaults: userDefaults, stores: stores, config: &config, state: &state) self.migrateOpenCode(userDefaults: userDefaults, stores: stores, config: &config, state: &state) } @@ -120,7 +119,6 @@ struct CodexBarConfigMigrator { (.opencode, "opencodeCookieSource"), (.factory, "factoryCookieSource"), (.minimax, "minimaxCookieSource"), - (.kimi, "kimiCookieSource"), (.augment, "augmentCookieSource"), (.amp, "ampCookieSource"), ] @@ -197,22 +195,6 @@ struct CodexBarConfigMigrator { } } - private static func migrateKimi( - userDefaults: UserDefaults, - stores: LegacyStores, - config: inout CodexBarConfig, - state: inout MigrationState) - { - var token = try? stores.kimiTokenStore.loadToken() - if token?.isEmpty ?? true { - token = userDefaults.string(forKey: "kimiManualCookieHeader") - } - if token != nil { state.sawLegacySecrets = true } - self.updateProvider(.kimi, config: &config, state: &state) { entry in - self.setIfEmpty(&entry.cookieHeader, token) - } - } - private static func migrateOpenCode( userDefaults: UserDefaults, stores: LegacyStores, diff --git a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift index d48511963..59f2b81e9 100644 --- a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift @@ -8,15 +8,10 @@ import SwiftUI struct KimiProviderImplementation: ProviderImplementation { let id: UsageProvider = .kimi - @MainActor - func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "web" } - } - @MainActor func observeSettings(_ settings: SettingsStore) { - _ = settings.kimiCookieSource - _ = settings.kimiManualCookieHeader + _ = settings.kimiUsageDataSource + _ = settings.kimiAPIToken } @MainActor @@ -24,36 +19,44 @@ struct KimiProviderImplementation: ProviderImplementation { .kimi(context.settings.kimiSettingsSnapshot(tokenOverride: context.tokenOverride)) } + @MainActor + func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { + context.settings.kimiUsageDataSource.rawValue + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.kimiUsageDataSource { + case .auto: .auto + case .oauth: .oauth + case .api: .api + } + } + @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { - let cookieBinding = Binding( - get: { context.settings.kimiCookieSource.rawValue }, + let usageBinding = Binding( + get: { context.settings.kimiUsageDataSource.rawValue }, set: { raw in - context.settings.kimiCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + context.settings.kimiUsageDataSource = KimiUsageDataSource(rawValue: raw) ?? .auto }) - let options = ProviderCookieSourceUI.options( - allowsOff: true, - keychainDisabled: context.settings.debugDisableKeychainAccess) - - let subtitle: () -> String? = { - ProviderCookieSourceUI.subtitle( - source: context.settings.kimiCookieSource, - keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies.", - manual: "Paste a cookie header or the kimi-auth token value.", - off: "Kimi cookies are disabled.") - } return [ ProviderSettingsPickerDescriptor( - id: "kimi-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies.", - dynamicSubtitle: subtitle, - binding: cookieBinding, - options: options, + id: "kimi-usage-source", + title: "Usage source", + subtitle: "Auto prefers the official Kimi CLI OAuth session, then falls back to KIMI_API_KEY.", + binding: usageBinding, + options: KimiUsageDataSource.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + }, isVisible: nil, - onChange: nil), + onChange: nil, + trailingText: { + guard context.settings.kimiUsageDataSource == .auto else { return nil } + let label = context.store.sourceLabel(for: .kimi) + return label == "auto" ? nil : label + }), ] } @@ -61,26 +64,26 @@ struct KimiProviderImplementation: ProviderImplementation { func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( - id: "kimi-cookie", - title: "", - subtitle: "", + id: "kimi-api-key", + title: "API key", + subtitle: "Optional. Auto mode uses ~/.kimi/credentials/kimi-code.json first, then this key.", kind: .secure, - placeholder: "Cookie: \u{2026}\n\nor paste the kimi-auth token value", - binding: context.stringBinding(\.kimiManualCookieHeader), + placeholder: "sk-...", + binding: context.stringBinding(\.kimiAPIToken), actions: [ ProviderSettingsActionDescriptor( - id: "kimi-open-console", - title: "Open Console", + id: "kimi-open-docs", + title: "Open Kimi Code Docs", style: .link, isVisible: nil, perform: { - if let url = URL(string: "https://www.kimi.com/code/console") { + if let url = URL(string: "https://www.kimi.com/code/docs/en/") { NSWorkspace.shared.open(url) } }), ], - isVisible: { context.settings.kimiCookieSource == .manual }, - onActivate: { context.settings.ensureKimiAuthTokenLoaded() }), + isVisible: nil, + onActivate: { context.settings.ensureKimiAPIKeyLoaded() }), ] } } diff --git a/Sources/CodexBar/Providers/Kimi/KimiSettingsStore.swift b/Sources/CodexBar/Providers/Kimi/KimiSettingsStore.swift index a79241d4b..48e03639a 100644 --- a/Sources/CodexBar/Providers/Kimi/KimiSettingsStore.swift +++ b/Sources/CodexBar/Providers/Kimi/KimiSettingsStore.swift @@ -2,35 +2,51 @@ import CodexBarCore import Foundation extension SettingsStore { - var kimiManualCookieHeader: String { - get { self.configSnapshot.providerConfig(for: .kimi)?.sanitizedCookieHeader ?? "" } + var kimiUsageDataSource: KimiUsageDataSource { + get { + let source = self.configSnapshot.providerConfig(for: .kimi)?.source + return Self.kimiUsageDataSource(from: source) + } set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .oauth: .oauth + case .api: .api + } self.updateProviderConfig(provider: .kimi) { entry in - entry.cookieHeader = self.normalizedConfigValue(newValue) + entry.source = source } - self.logSecretUpdate(provider: .kimi, field: "cookieHeader", value: newValue) + self.logProviderModeChange(provider: .kimi, field: "usageSource", value: newValue.rawValue) } } - var kimiCookieSource: ProviderCookieSource { - get { self.resolvedCookieSource(provider: .kimi, fallback: .auto) } + var kimiAPIToken: String { + get { self.configSnapshot.providerConfig(for: .kimi)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .kimi) { entry in - entry.cookieSource = newValue + entry.apiKey = self.normalizedConfigValue(newValue) } - self.logProviderModeChange(provider: .kimi, field: "cookieSource", value: newValue.rawValue) + self.logSecretUpdate(provider: .kimi, field: "apiKey", value: newValue) } } - func ensureKimiAuthTokenLoaded() {} + func ensureKimiAPIKeyLoaded() {} } extension SettingsStore { - func kimiSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.KimiProviderSettings { - _ = tokenOverride - self.ensureKimiAuthTokenLoaded() - return ProviderSettingsSnapshot.KimiProviderSettings( - cookieSource: self.kimiCookieSource, - manualCookieHeader: self.kimiManualCookieHeader) + func kimiSettingsSnapshot(tokenOverride _: TokenAccountOverride?) -> ProviderSettingsSnapshot.KimiProviderSettings { + ProviderSettingsSnapshot.KimiProviderSettings(usageDataSource: self.kimiUsageDataSource) + } + + private static func kimiUsageDataSource(from source: ProviderSourceMode?) -> KimiUsageDataSource { + guard let source else { return .auto } + switch source { + case .auto, .web, .cli: + return .auto + case .oauth: + return .oauth + case .api: + return .api + } } } diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 5ac7f16f6..8418d780e 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -41,7 +41,7 @@ extension SettingsStore { _ = self.factoryCookieSource _ = self.minimaxCookieSource _ = self.minimaxAPIRegion - _ = self.kimiCookieSource + _ = self.kimiUsageDataSource _ = self.augmentCookieSource _ = self.ampCookieSource _ = self.ollamaCookieSource @@ -61,7 +61,7 @@ extension SettingsStore { _ = self.factoryCookieHeader _ = self.minimaxCookieHeader _ = self.minimaxAPIToken - _ = self.kimiManualCookieHeader + _ = self.kimiAPIToken _ = self.kimiK2APIToken _ = self.kiloAPIToken _ = self.augmentCookieHeader diff --git a/Sources/CodexBar/UsageStore+Logging.swift b/Sources/CodexBar/UsageStore+Logging.swift index d5c9830d0..11be0a612 100644 --- a/Sources/CodexBar/UsageStore+Logging.swift +++ b/Sources/CodexBar/UsageStore+Logging.swift @@ -13,7 +13,7 @@ extension UsageStore { "opencodegoCookieSource": self.settings.opencodegoCookieSource.rawValue, "factoryCookieSource": self.settings.factoryCookieSource.rawValue, "minimaxCookieSource": self.settings.minimaxCookieSource.rawValue, - "kimiCookieSource": self.settings.kimiCookieSource.rawValue, + "kimiUsageSource": self.settings.kimiUsageDataSource.rawValue, "augmentCookieSource": self.settings.augmentCookieSource.rawValue, "ampCookieSource": self.settings.ampCookieSource.rawValue, "ollamaCookieSource": self.settings.ollamaCookieSource.rawValue, diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index e43073e82..bc7405c8e 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -161,12 +161,9 @@ struct TokenAccountCLIContext { cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .kimi: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( kimi: ProviderSettingsSnapshot.KimiProviderSettings( - cookieSource: cookieSource, - manualCookieHeader: cookieHeader)) + usageDataSource: Self.kimiUsageDataSource(from: config?.source))) case .zai: return self.makeSnapshot( zai: ProviderSettingsSnapshot.ZaiProviderSettings(apiRegion: self.resolveZaiRegion(config))) @@ -406,3 +403,17 @@ struct TokenAccountCLIContext { manualCookieHeader: manualCookieHeader) } } + +private extension TokenAccountCLIContext { + static func kimiUsageDataSource(from source: ProviderSourceMode?) -> KimiUsageDataSource { + guard let source else { return .auto } + switch source { + case .auto, .web, .cli: + return .auto + case .oauth: + return .oauth + case .api: + return .api + } + } +} diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiAPIError.swift b/Sources/CodexBarCore/Providers/Kimi/KimiAPIError.swift index aee5e13a0..f8c27dc02 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiAPIError.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiAPIError.swift @@ -11,11 +11,11 @@ public enum KimiAPIError: LocalizedError, Sendable, Equatable { public var errorDescription: String? { switch self { case .missingToken: - "Kimi auth token is missing. Please add your JWT token from the Kimi console." + "Kimi Code credentials are missing. Sign into the Kimi CLI or provide KIMI_API_KEY." case .invalidToken: - "Kimi auth token is invalid or expired. Please refresh your token." + "Kimi Code credentials are invalid or expired." case let .invalidRequest(message): - "Invalid request: \(message)" + "Kimi request failed: \(message)" case let .networkError(message): "Kimi network error: \(message)" case let .apiError(message): diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCookieHeader.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCookieHeader.swift deleted file mode 100644 index d4dcbf58c..000000000 --- a/Sources/CodexBarCore/Providers/Kimi/KimiCookieHeader.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation - -public struct KimiCookieOverride: Sendable { - public let token: String - - public init(token: String) { - self.token = token - } -} - -public enum KimiCookieHeader { - private static let log = CodexBarLog.logger(LogCategories.kimiCookie) - private static let headerPatterns: [String] = [ - #"(?i)kimi-auth=([A-Za-z0-9._\-+=/]+)"#, - #"(?i)-H\s*'Cookie:\s*([^']+)'"#, - #"(?i)-H\s*"Cookie:\s*([^"]+)""#, - #"(?i)\bcookie:\s*'([^']+)'"#, - #"(?i)\bcookie:\s*"([^"]+)""#, - #"(?i)\bcookie:\s*([^\r\n]+)"#, - ] - - public static func resolveCookieOverride(context: ProviderFetchContext) -> KimiCookieOverride? { - if let settings = context.settings?.kimi, settings.cookieSource == .manual { - if let manual = settings.manualCookieHeader, !manual.isEmpty { - return self.override(from: manual) - } - } - - if let envToken = self.override(from: context.env["KIMI_MANUAL_COOKIE"]) { - return envToken - } - if let envToken = self.override(from: context.env["KIMI_AUTH_TOKEN"]) { - return envToken - } - - return nil - } - - public static func override(from raw: String?) -> KimiCookieOverride? { - guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - return nil - } - - if let token = self.extractKIMAuthToken(from: raw) { - return KimiCookieOverride(token: token) - } - - if let cookieHeader = self.extractHeader(from: raw), - let token = self.extractKIMAuthToken(from: cookieHeader) - { - return KimiCookieOverride(token: token) - } - - if raw.hasPrefix("eyJ"), raw.split(separator: ".").count == 3 { - return KimiCookieOverride(token: raw) - } - - return nil - } - - private static func extractKIMAuthToken(from raw: String) -> String? { - let patterns = [ - #"(?i)kimi-auth=([A-Za-z0-9._\-+=/]+)"#, - #"(?i)kimi-auth:\s*([A-Za-z0-9._\-+=/]+)"#, - ] - - for pattern in patterns { - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { continue } - let range = NSRange(raw.startIndex..= 2, - let captureRange = Range(match.range(at: 1), in: raw) - else { - continue - } - let token = String(raw[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) - if !token.isEmpty { return token } - } - - return nil - } - - private static func extractHeader(from raw: String) -> String? { - for pattern in self.headerPatterns { - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { continue } - let range = NSRange(raw.startIndex..= 2, - let captureRange = Range(match.range(at: 1), in: raw) - else { - continue - } - let captured = String(raw[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) - if !captured.isEmpty { return captured } - } - return nil - } -} diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift deleted file mode 100644 index b3f6deef6..000000000 --- a/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift +++ /dev/null @@ -1,184 +0,0 @@ -import Foundation - -#if os(macOS) -import SweetCookieKit - -public enum KimiCookieImporter { - private static let log = CodexBarLog.logger(LogCategories.kimiCookie) - private static let cookieClient = BrowserCookieClient() - private static let cookieDomains = ["www.kimi.com", "kimi.com"] - private static let cookieImportOrder: BrowserCookieImportOrder = - ProviderDefaults.metadata[.kimi]?.browserCookieOrder ?? Browser.defaultImportOrder - - public struct SessionInfo: Sendable { - public let cookies: [HTTPCookie] - public let sourceLabel: String - - public init(cookies: [HTTPCookie], sourceLabel: String) { - self.cookies = cookies - self.sourceLabel = sourceLabel - } - - public var authToken: String? { - self.cookies.first(where: { $0.name == "kimi-auth" })?.value - } - } - - public static func importSessions( - browserDetection: BrowserDetection = BrowserDetection(), - logger: ((String) -> Void)? = nil) throws -> [SessionInfo] - { - var sessions: [SessionInfo] = [] - let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) - for browserSource in candidates { - do { - let perSource = try self.importSessions(from: browserSource, logger: logger) - sessions.append(contentsOf: perSource) - } catch { - BrowserCookieAccessGate.recordIfNeeded(error) - self.emit( - "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", - logger: logger) - } - } - - guard !sessions.isEmpty else { - throw KimiCookieImportError.noCookies - } - return sessions - } - - public static func importSessions( - from browserSource: Browser, - logger: ((String) -> Void)? = nil) throws -> [SessionInfo] - { - let query = BrowserCookieQuery(domains: self.cookieDomains) - let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } - let sources = try Self.cookieClient.codexBarRecords( - matching: query, - in: browserSource, - logger: log) - - var sessions: [SessionInfo] = [] - let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) - let sortedGroups = grouped.values.sorted { lhs, rhs in - self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) - } - - for group in sortedGroups where !group.isEmpty { - let label = self.mergedLabel(for: group) - let mergedRecords = self.mergeRecords(group) - guard !mergedRecords.isEmpty else { continue } - let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin) - guard !httpCookies.isEmpty else { continue } - - // Only include sessions that have the kimi-auth cookie - guard httpCookies.contains(where: { $0.name == "kimi-auth" }) else { - continue - } - - log("Found kimi-auth cookie in \(label)") - sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: label)) - } - return sessions - } - - public static func importSession( - browserDetection: BrowserDetection = BrowserDetection(), - logger: ((String) -> Void)? = nil) throws -> SessionInfo - { - let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) - guard let first = sessions.first else { - throw KimiCookieImportError.noCookies - } - return first - } - - public static func hasSession( - browserDetection: BrowserDetection = BrowserDetection(), - logger: ((String) -> Void)? = nil) -> Bool - { - do { - return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty - } catch { - return false - } - } - - private static func cookieNames(from cookies: [HTTPCookie]) -> String { - let names = Set(cookies.map { "\($0.name)@\($0.domain)" }).sorted() - return names.joined(separator: ", ") - } - - private static func emit(_ message: String, logger: ((String) -> Void)?) { - logger?("[kimi-cookie] \(message)") - self.log.debug(message) - } - - private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { - guard let base = sources.map(\.label).min() else { - return "Unknown" - } - if base.hasSuffix(" (Network)") { - return String(base.dropLast(" (Network)".count)) - } - return base - } - - private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { - let sortedSources = sources.sorted { lhs, rhs in - self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) - } - var mergedByKey: [String: BrowserCookieRecord] = [:] - for source in sortedSources { - for record in source.records { - let key = self.recordKey(record) - if let existing = mergedByKey[key] { - if self.shouldReplace(existing: existing, candidate: record) { - mergedByKey[key] = record - } - } else { - mergedByKey[key] = record - } - } - } - return Array(mergedByKey.values) - } - - private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { - switch kind { - case .network: 0 - case .primary: 1 - case .safari: 2 - } - } - - private static func recordKey(_ record: BrowserCookieRecord) -> String { - "\(record.name)|\(record.domain)|\(record.path)" - } - - private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { - switch (existing.expires, candidate.expires) { - case let (lhs?, rhs?): - rhs > lhs - case (nil, .some): - true - case (.some, nil): - false - case (nil, nil): - false - } - } -} - -enum KimiCookieImportError: LocalizedError { - case noCookies - - var errorDescription: String? { - switch self { - case .noCookies: - "No Kimi session cookies found in browsers." - } - } -} -#endif diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift index 8d15d5f5d..1db0cccd8 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift @@ -1,35 +1,27 @@ import Foundation -struct KimiUsageResponse: Codable { - let usages: [KimiUsage] -} - -struct KimiUsage: Codable { - let scope: String - let detail: KimiUsageDetail - let limits: [KimiRateLimit]? -} - -public struct KimiUsageDetail: Codable, Sendable { - public let limit: String - public let used: String? - public let remaining: String? - public let resetTime: String? +public struct KimiUsageRow: Codable, Sendable, Equatable { + public let label: String + public let used: Int + public let limit: Int + public let windowMinutes: Int? + public let resetAt: String? - public init(limit: String, used: String?, remaining: String?, resetTime: String?) { - self.limit = limit + public init(label: String, used: Int, limit: Int, windowMinutes: Int?, resetAt: String?) { + self.label = label self.used = used - self.remaining = remaining - self.resetTime = resetTime + self.limit = limit + self.windowMinutes = windowMinutes + self.resetAt = resetAt } } -struct KimiRateLimit: Codable { - let window: KimiWindow - let detail: KimiUsageDetail -} +public struct KimiUsagePayload: Sendable, Equatable { + public let summary: KimiUsageRow? + public let limits: [KimiUsageRow] -struct KimiWindow: Codable { - let duration: Int - let timeUnit: String + public init(summary: KimiUsageRow?, limits: [KimiUsageRow]) { + self.summary = summary + self.limits = limits + } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Kimi/KimiOAuthCredentials.swift new file mode 100644 index 000000000..1ec95f5ff --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kimi/KimiOAuthCredentials.swift @@ -0,0 +1,177 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct KimiOAuthCredentials: Sendable, Codable, Equatable { + public let accessToken: String + public let refreshToken: String + public let expiresAt: TimeInterval + public let scope: String + public let tokenType: String + public let expiresIn: TimeInterval + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresAt = "expires_at" + case scope + case tokenType = "token_type" + case expiresIn = "expires_in" + } + + public var needsRefresh: Bool { + self.accessToken.isEmpty || Date().timeIntervalSince1970 >= (self.expiresAt - 300) + } +} + +public enum KimiOAuthCredentialsError: LocalizedError, Sendable, Equatable { + case missingCredentials + case invalidCredentials + case refreshFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Kimi Code CLI credentials not found. Run `kimi` and sign in first." + case .invalidCredentials: + "Kimi Code CLI credentials are invalid." + case let .refreshFailed(message): + "Failed to refresh Kimi Code CLI credentials: \(message)" + } + } +} + +enum KimiOAuthCredentialsStore { + private static let clientID = "17e5f671-d194-4dfb-9706-5516cb48c098" + + static func load( + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) throws -> KimiOAuthCredentials + { + let url = KimiSettingsReader.credentialsFileURL(environment: env, homeDirectory: fileManager.homeDirectoryForCurrentUser) + guard let data = try? Data(contentsOf: url) else { + throw KimiOAuthCredentialsError.missingCredentials + } + guard let credentials = try? JSONDecoder().decode(KimiOAuthCredentials.self, from: data), + !credentials.accessToken.isEmpty || !credentials.refreshToken.isEmpty + else { + throw KimiOAuthCredentialsError.invalidCredentials + } + return credentials + } + + static func save( + _ credentials: KimiOAuthCredentials, + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) throws + { + let url = KimiSettingsReader.credentialsFileURL(environment: env, homeDirectory: fileManager.homeDirectoryForCurrentUser) + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONEncoder().encode(credentials) + try data.write(to: url, options: .atomic) + #if os(macOS) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + #endif + } + + static func refresh( + _ credentials: KimiOAuthCredentials, + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) async throws -> KimiOAuthCredentials + { + guard !credentials.refreshToken.isEmpty else { + throw KimiOAuthCredentialsError.refreshFailed("missing refresh token") + } + + let refreshURL = KimiSettingsReader.oauthHost(environment: env) + .appending(path: "api/oauth/token") + + var request = URLRequest(url: refreshURL) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type") + for (header, value) in self.commonHeaders(env: env, fileManager: fileManager) { + request.setValue(value, forHTTPHeaderField: header) + } + + let body = [ + URLQueryItem(name: "client_id", value: Self.clientID), + URLQueryItem(name: "grant_type", value: "refresh_token"), + URLQueryItem(name: "refresh_token", value: credentials.refreshToken), + ] + request.httpBody = body.percentEncodedQuery?.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw KimiOAuthCredentialsError.refreshFailed("invalid response") + } + guard http.statusCode == 200 else { + let payload = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" + throw KimiOAuthCredentialsError.refreshFailed(payload) + } + + guard let refreshed = try? JSONDecoder().decode(KimiOAuthCredentials.self, from: data), + !refreshed.accessToken.isEmpty + else { + throw KimiOAuthCredentialsError.refreshFailed("invalid payload") + } + + try self.save(refreshed, env: env, fileManager: fileManager) + return refreshed + } + + private static func commonHeaders( + env: [String: String], + fileManager: FileManager) -> [String: String] + { + let processInfo = ProcessInfo.processInfo + let model = self.asciiHeaderValue("\(processInfo.operatingSystemVersionString) \(processInfo.processorCount)cpu") + let deviceName = self.asciiHeaderValue(Host.current().localizedName ?? "unknown") + let osVersion = self.asciiHeaderValue(processInfo.operatingSystemVersionString) + let deviceID = self.deviceID(env: env, fileManager: fileManager) + + return [ + "X-Msh-Platform": "codexbar", + "X-Msh-Version": "1.0", + "X-Msh-Device-Name": deviceName, + "X-Msh-Device-Model": model, + "X-Msh-Os-Version": osVersion, + "X-Msh-Device-Id": deviceID, + ] + } + + private static func deviceID( + env: [String: String], + fileManager: FileManager) -> String + { + let url = KimiSettingsReader.deviceIDFileURL(environment: env, homeDirectory: fileManager.homeDirectoryForCurrentUser) + if let raw = try? String(contentsOf: url, encoding: .utf8), + let cleaned = KimiSettingsReader.cleaned(raw) + { + return cleaned + } + + let generated = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + try? fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try? generated.write(to: url, atomically: true, encoding: .utf8) + #if os(macOS) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + #endif + return generated + } + + private static func asciiHeaderValue(_ raw: String) -> String { + let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let sanitized = String(String.UnicodeScalarView(cleaned.unicodeScalars.filter { $0.isASCII })) + return sanitized.isEmpty ? "unknown" : sanitized + } +} + +private extension Array where Element == URLQueryItem { + var percentEncodedQuery: String? { + var components = URLComponents() + components.queryItems = self + return components.percentEncodedQuery + } +} diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index 711c20bc8..464fc4fc7 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -9,20 +9,20 @@ public enum KimiProviderDescriptor { id: .kimi, metadata: ProviderMetadata( id: .kimi, - displayName: "Kimi", + displayName: "Kimi Code", sessionLabel: "Weekly", weeklyLabel: "Rate Limit", opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Kimi usage", + toggleTitle: "Show Kimi Code usage", cliName: "kimi", defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, browserCookieOrder: nil, - dashboardURL: "https://www.kimi.com/code/console", + dashboardURL: "https://www.kimi.com/code/", statusPageURL: nil), branding: ProviderBranding( iconStyle: .kimi, @@ -30,85 +30,79 @@ public enum KimiProviderDescriptor { color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Kimi cost summary is not supported." }), + noDataMessage: { "Kimi Code cost summary is not supported." }), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .web], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [KimiWebFetchStrategy()] })), + sourceModes: [.auto, .oauth, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "kimi", - aliases: ["kimi-ai"], + aliases: ["kimi-code"], versionDetector: nil)) } -} -struct KimiWebFetchStrategy: ProviderFetchStrategy { - let id: String = "kimi.web" - let kind: ProviderFetchKind = .web - private static let log = CodexBarLog.logger(LogCategories.kimiWeb) + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + let oauth = KimiOAuthFetchStrategy() + let api = KimiAPIFetchStrategy() - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - if KimiCookieHeader.resolveCookieOverride(context: context) != nil { - return true - } - - if Self.resolveToken(environment: context.env) != nil { - return true + switch context.sourceMode { + case .oauth: + return [oauth] + case .api: + return [api] + case .auto: + return [oauth, api] + case .web, .cli: + return [] } + } +} - #if os(macOS) - if context.settings?.kimi?.cookieSource != .off { - return KimiCookieImporter.hasSession() - } - #endif +private struct KimiOAuthFetchStrategy: ProviderFetchStrategy { + let id: String = "kimi.oauth" + let kind: ProviderFetchKind = .oauth - return false + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + (try? KimiOAuthCredentialsStore.load(env: context.env)) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let token = self.resolveToken(context: context) else { - throw KimiAPIError.missingToken + var credentials = try KimiOAuthCredentialsStore.load(env: context.env) + if credentials.needsRefresh { + credentials = try await KimiOAuthCredentialsStore.refresh(credentials, env: context.env) } - let snapshot = try await KimiUsageFetcher.fetchUsage(authToken: token) - return self.makeResult( - usage: snapshot.toUsageSnapshot(), - sourceLabel: "web") + let snapshot = try await KimiUsageFetcher.fetchUsage( + apiKey: credentials.accessToken, + environment: context.env) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "oauth") } func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { - if case KimiAPIError.missingToken = error { return false } - if case KimiAPIError.invalidToken = error { return false } - return true + guard context.sourceMode == .auto else { return false } + return error is KimiOAuthCredentialsError || error is KimiAPIError } +} - private func resolveToken(context: ProviderFetchContext) -> String? { - // Check manual cookie first (highest priority when set) - if let override = KimiCookieHeader.resolveCookieOverride(context: context) { - return override.token - } +private struct KimiAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "kimi.api" + let kind: ProviderFetchKind = .apiToken - // Try browser cookie import when auto mode is enabled - #if os(macOS) - if context.settings?.kimi?.cookieSource != .off { - do { - let session = try KimiCookieImporter.importSession() - if let token = session.authToken { - return token - } - } catch { - // No browser cookies found - } - } - #endif + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + ProviderTokenResolver.kimiAPIKey(environment: context.env) != nil + } - // Fall back to environment - if let override = Self.resolveToken(environment: context.env) { - return override + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = ProviderTokenResolver.kimiAPIKey(environment: context.env) else { + throw KimiAPIError.missingToken } - return nil + + let snapshot = try await KimiUsageFetcher.fetchUsage( + apiKey: apiKey, + environment: context.env) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "api") } - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.kimiAuthToken(environment: environment) + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift b/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift index bcb29d9c0..87aa7b024 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift @@ -1,12 +1,64 @@ import Foundation public enum KimiSettingsReader { - public static func authToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { - let raw = environment["KIMI_AUTH_TOKEN"] ?? environment["kimi_auth_token"] - return self.cleaned(raw) + public static let apiTokenKeys = [ + "KIMI_CODE_API_KEY", + "KIMI_API_KEY", + ] + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiTokenKeys { + if let cleaned = self.cleaned(environment[key]) { + return cleaned + } + } + return nil + } + + public static func codingBaseURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let raw = self.cleaned(environment["KIMI_CODE_BASE_URL"]), + let url = URL(string: raw) + { + return url + } + return URL(string: "https://api.kimi.com/coding/v1")! + } + + public static func oauthHost(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + let raw = self.cleaned(environment["KIMI_CODE_OAUTH_HOST"]) + ?? self.cleaned(environment["KIMI_OAUTH_HOST"]) + ?? "https://auth.kimi.com" + return URL(string: raw)! + } + + public static func shareDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL + { + if let raw = self.cleaned(environment["KIMI_HOME"]) { + return URL(fileURLWithPath: raw, isDirectory: true) + } + return homeDirectory.appendingPathComponent(".kimi", isDirectory: true) + } + + public static func credentialsFileURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL + { + self.shareDirectory(environment: environment, homeDirectory: homeDirectory) + .appendingPathComponent("credentials", isDirectory: true) + .appendingPathComponent("kimi-code.json", isDirectory: false) + } + + public static func deviceIDFileURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL + { + self.shareDirectory(environment: environment, homeDirectory: homeDirectory) + .appendingPathComponent("device_id", isDirectory: false) } - private static func cleaned(_ raw: String?) -> String? { + static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageDataSource.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageDataSource.swift new file mode 100644 index 000000000..b9fa1d675 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageDataSource.swift @@ -0,0 +1,19 @@ +import Foundation + +public enum KimiUsageDataSource: String, CaseIterable, Identifiable, Sendable { + case auto + case oauth + case api + + public var id: String { + self.rawValue + } + + public var displayName: String { + switch self { + case .auto: "Auto" + case .oauth: "CLI OAuth" + case .api: "API Key" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift index c6d6c2a2e..be4a84b2f 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift @@ -6,45 +6,22 @@ import FoundationNetworking public struct KimiUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.kimiAPI) - private static let usageURL = - URL(string: "https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages")! - - public static func fetchUsage(authToken: String, now: Date = Date()) async throws -> KimiUsageSnapshot { - // Decode JWT to get session info - let sessionInfo = self.decodeSessionInfo(from: authToken) - - var request = URLRequest(url: self.usageURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.setValue("kimi-auth=\(authToken)", forHTTPHeaderField: "Cookie") - request.setValue("https://www.kimi.com", forHTTPHeaderField: "Origin") - request.setValue("https://www.kimi.com/code/console", forHTTPHeaderField: "Referer") - request.setValue("*/*", forHTTPHeaderField: "Accept") - request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - request.setValue("1", forHTTPHeaderField: "connect-protocol-version") - request.setValue("en-US", forHTTPHeaderField: "x-language") - request.setValue("web", forHTTPHeaderField: "x-msh-platform") - request.setValue(TimeZone.current.identifier, forHTTPHeaderField: "r-timezone") - - // Add session-specific headers from JWT - if let sessionInfo { - if let deviceId = sessionInfo.deviceId { - request.setValue(deviceId, forHTTPHeaderField: "x-msh-device-id") - } - if let sessionId = sessionInfo.sessionId { - request.setValue(sessionId, forHTTPHeaderField: "x-msh-session-id") - } - if let trafficId = sessionInfo.trafficId { - request.setValue(trafficId, forHTTPHeaderField: "x-traffic-id") - } + + public static func fetchUsage( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) async throws -> KimiUsageSnapshot + { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw KimiAPIError.missingToken } - let requestBody = ["scope": ["FEATURE_CODING"]] - request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody) + let url = KimiSettingsReader.codingBaseURL(environment: environment) + .appending(path: "usages") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { @@ -52,61 +29,144 @@ public struct KimiUsageFetcher: Sendable { } guard httpResponse.statusCode == 200 else { - let responseBody = String(data: data, encoding: .utf8) ?? "" - Self.log.error("Kimi API returned \(httpResponse.statusCode): \(responseBody)") - - if httpResponse.statusCode == 401 { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("Kimi API returned \(httpResponse.statusCode): \(body)") + switch httpResponse.statusCode { + case 401, 403: throw KimiAPIError.invalidToken - } - if httpResponse.statusCode == 403 { - throw KimiAPIError.invalidToken - } - if httpResponse.statusCode == 400 { + case 400: throw KimiAPIError.invalidRequest("Bad request") + default: + throw KimiAPIError.apiError("HTTP \(httpResponse.statusCode)") } - throw KimiAPIError.apiError("HTTP \(httpResponse.statusCode)") } - let usageResponse = try JSONDecoder().decode(KimiUsageResponse.self, from: data) - guard let codingUsage = usageResponse.usages.first(where: { $0.scope == "FEATURE_CODING" }) else { - throw KimiAPIError.parseFailed("FEATURE_CODING scope not found in response") + let payload = try Self.parsePayload(data: data) + return KimiUsageSnapshot(summary: payload.summary, limits: payload.limits, updatedAt: now) + } + + static func _parsePayloadForTesting(_ data: Data) throws -> KimiUsagePayload { + try self.parsePayload(data: data) + } + + private static func parsePayload(data: Data) throws -> KimiUsagePayload { + guard let json = try? JSONSerialization.jsonObject(with: data), + let payload = json as? [String: Any] + else { + throw KimiAPIError.parseFailed("Root JSON is not an object.") + } + + let summary = self.summaryRow(from: payload["usage"]) + let limits = self.limitRows(from: payload["limits"]) + if summary == nil && limits.isEmpty { + throw KimiAPIError.parseFailed("No usage rows found.") } - return KimiUsageSnapshot( - weekly: codingUsage.detail, - rateLimit: codingUsage.limits?.first?.detail, - updatedAt: now) + return KimiUsagePayload(summary: summary, limits: limits) } - private static func decodeSessionInfo(from jwt: String) -> SessionInfo? { - let parts = jwt.split(separator: ".", maxSplits: 2) - guard parts.count == 3 else { return nil } - - // Convert base64url to base64 for JWT decoding - // base64url uses - and _ instead of + and / - var payload = String(parts[1]) - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - // Add padding if needed - while payload.count % 4 != 0 { - payload += "=" + private static func summaryRow(from raw: Any?) -> KimiUsageRow? { + guard let map = raw as? [String: Any] else { return nil } + return self.row( + from: map, + defaultLabel: "Weekly limit", + windowMinutes: nil) + } + + private static func limitRows(from raw: Any?) -> [KimiUsageRow] { + guard let items = raw as? [Any] else { return [] } + var rows: [KimiUsageRow] = [] + rows.reserveCapacity(items.count) + + for (index, item) in items.enumerated() { + guard let map = item as? [String: Any] else { continue } + let detail = (map["detail"] as? [String: Any]) ?? map + let window = map["window"] as? [String: Any] + let label = self.limitLabel(item: map, detail: detail, window: window, index: index) + let windowMinutes = self.windowMinutes(from: window ?? detail) + if let row = self.row(from: detail, defaultLabel: label, windowMinutes: windowMinutes) { + rows.append(row) + } } - guard let payloadData = Data(base64Encoded: payload), - let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] - else { - return nil + return rows + } + + private static func row( + from map: [String: Any], + defaultLabel: String, + windowMinutes: Int?) -> KimiUsageRow? + { + let limit = self.intValue(map["limit"]) + var used = self.intValue(map["used"]) + if used == nil, + let remaining = self.intValue(map["remaining"]), + let limit + { + used = max(0, limit - remaining) } - return SessionInfo( - deviceId: json["device_id"] as? String, - sessionId: json["ssid"] as? String, - trafficId: json["sub"] as? String) + guard used != nil || limit != nil else { return nil } + return KimiUsageRow( + label: (map["name"] as? String) ?? (map["title"] as? String) ?? defaultLabel, + used: used ?? 0, + limit: limit ?? 0, + windowMinutes: windowMinutes, + resetAt: (map["reset_at"] as? String) + ?? (map["resetAt"] as? String) + ?? (map["reset_time"] as? String) + ?? (map["resetTime"] as? String)) } - private struct SessionInfo { - let deviceId: String? - let sessionId: String? - let trafficId: String? + private static func limitLabel( + item: [String: Any], + detail: [String: Any], + window: [String: Any]?, + index: Int) -> String + { + for key in ["name", "title", "scope"] { + if let value = (item[key] as? String) ?? (detail[key] as? String), !value.isEmpty { + return value + } + } + + if let minutes = self.windowMinutes(from: window ?? detail) { + if minutes >= 60, minutes % 60 == 0 { + return "\(minutes / 60)h limit" + } + return "\(minutes)m limit" + } + + return "Limit #\(index + 1)" + } + + private static func windowMinutes(from map: [String: Any]) -> Int? { + let duration = self.intValue(map["duration"]) + let timeUnit = ((map["timeUnit"] as? String) ?? (map["time_unit"] as? String) ?? "").uppercased() + guard let duration else { return nil } + + if timeUnit.contains("MINUTE") || timeUnit.isEmpty { + return duration + } + if timeUnit.contains("HOUR") { + return duration * 60 + } + if timeUnit.contains("DAY") { + return duration * 24 * 60 + } + return nil + } + + private static func intValue(_ raw: Any?) -> Int? { + switch raw { + case let value as Int: + return value + case let value as Double: + return Int(value) + case let value as String: + return Int(value) + default: + return nil + } } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift index d19ff420d..ccf0bd95b 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift @@ -1,81 +1,62 @@ import Foundation -public struct KimiUsageSnapshot: Sendable { - public let weekly: KimiUsageDetail - public let rateLimit: KimiUsageDetail? +public struct KimiUsageSnapshot: Sendable, Equatable { + public let summary: KimiUsageRow? + public let limits: [KimiUsageRow] public let updatedAt: Date - public init(weekly: KimiUsageDetail, rateLimit: KimiUsageDetail?, updatedAt: Date) { - self.weekly = weekly - self.rateLimit = rateLimit + public init(summary: KimiUsageRow?, limits: [KimiUsageRow], updatedAt: Date) { + self.summary = summary + self.limits = limits self.updatedAt = updatedAt } - private static func parseDate(_ dateString: String?) -> Date? { - guard let dateString else { return nil } - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: dateString) { + private static func parseDate(_ raw: String?) -> Date? { + guard let raw else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: raw) { return date } - let fallback = ISO8601DateFormatter() - fallback.formatOptions = [.withInternetDateTime] - return fallback.date(from: dateString) + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: raw) } - private static func minutesFromNow(_ date: Date?) -> Int? { - guard let date else { return nil } - let minutes = Int(date.timeIntervalSince(Date()) / 60) - return minutes > 0 ? minutes : nil + private static func rateWindow(from row: KimiUsageRow, prefixLabel: Bool) -> RateWindow { + let clampedLimit = max(row.limit, 0) + let clampedUsed = max(0, min(row.used, clampedLimit == 0 ? row.used : clampedLimit)) + let usedPercent = clampedLimit > 0 ? (Double(clampedUsed) / Double(clampedLimit) * 100) : 0 + let descriptionPrefix = prefixLabel ? "\(row.label): " : "" + + return RateWindow( + usedPercent: usedPercent, + windowMinutes: row.windowMinutes, + resetsAt: Self.parseDate(row.resetAt), + resetDescription: "\(descriptionPrefix)\(clampedUsed)/\(clampedLimit)") } } extension KimiUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - // Parse weekly quota - let weeklyLimit = Int(weekly.limit) ?? 0 - let weeklyRemaining = Int(weekly.remaining ?? "") - let weeklyUsed = Int(weekly.used ?? "") ?? { - guard let remaining = weeklyRemaining else { return 0 } - return max(0, weeklyLimit - remaining) - }() - - let weeklyPercent = weeklyLimit > 0 ? Double(weeklyUsed) / Double(weeklyLimit) * 100 : 0 - - let weeklyWindow = RateWindow( - usedPercent: weeklyPercent, - windowMinutes: nil, // Weekly doesn't have a fixed window like rate limit - resetsAt: Self.parseDate(self.weekly.resetTime), - resetDescription: "\(weeklyUsed)/\(weeklyLimit) requests") - - // Parse rate limit if available - var rateLimitWindow: RateWindow? - if let rateLimit = self.rateLimit { - let rateLimitValue = Int(rateLimit.limit) ?? 0 - let rateRemaining = Int(rateLimit.remaining ?? "") - let rateUsed = Int(rateLimit.used ?? "") ?? { - guard let remaining = rateRemaining else { return 0 } - return max(0, rateLimitValue - remaining) - }() - let ratePercent = rateLimitValue > 0 ? Double(rateUsed) / Double(rateLimitValue) * 100 : 0 - - rateLimitWindow = RateWindow( - usedPercent: ratePercent, - windowMinutes: 300, // 300 minutes = 5 hours - resetsAt: Self.parseDate(rateLimit.resetTime), - resetDescription: "Rate: \(rateUsed)/\(rateLimitValue) per 5 hours") - } - let identity = ProviderIdentitySnapshot( providerID: .kimi, accountEmail: nil, accountOrganization: nil, loginMethod: nil) + let summaryWindow = self.summary.map { Self.rateWindow(from: $0, prefixLabel: false) } + let secondaryWindow = self.limits.indices.contains(0) + ? Self.rateWindow(from: self.limits[0], prefixLabel: true) + : nil + let tertiaryWindow = self.limits.indices.contains(1) + ? Self.rateWindow(from: self.limits[1], prefixLabel: true) + : nil + return UsageSnapshot( - primary: weeklyWindow, - secondary: rateLimitWindow, - tertiary: nil, + primary: summaryWindow, + secondary: secondaryWindow, + tertiary: tertiaryWindow, providerCost: nil, updatedAt: self.updatedAt, identity: identity) diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 63aa6221c..361fc50cb 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -177,12 +177,10 @@ public struct ProviderSettingsSnapshot: Sendable { } public struct KimiProviderSettings: Sendable { - public let cookieSource: ProviderCookieSource - public let manualCookieHeader: String? + public let usageDataSource: KimiUsageDataSource - public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { - self.cookieSource = cookieSource - self.manualCookieHeader = manualCookieHeader + public init(usageDataSource: KimiUsageDataSource) { + self.usageDataSource = usageDataSource } } diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 85113cc26..541c0e484 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -46,6 +46,10 @@ public enum ProviderTokenResolver { self.kimiAuthResolution(environment: environment)?.token } + public static func kimiAPIKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.kimiAPIKeyResolution(environment: environment)?.token + } + public static func kimiK2Token(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.kimiK2Resolution(environment: environment)?.token } @@ -110,20 +114,13 @@ public enum ProviderTokenResolver { public static func kimiAuthResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { - if let resolution = self.resolveEnv(KimiSettingsReader.authToken(environment: environment)) { - return resolution - } - #if os(macOS) - do { - let session = try KimiCookieImporter.importSession() - if let token = session.authToken { - return ProviderTokenResolution(token: token, source: .environment) - } - } catch { - // No browser cookies found, continue to fallback - } - #endif - return nil + self.kimiAPIKeyResolution(environment: environment) + } + + public static func kimiAPIKeyResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(KimiSettingsReader.apiKey(environment: environment)) } public static func kimiK2Resolution( diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index bcac5c943..b65efe81b 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -4,305 +4,190 @@ import Testing struct KimiSettingsReaderTests { @Test - func `reads token from environment variable`() { - let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] - let token = KimiSettingsReader.authToken(environment: env) - #expect(token == "test.jwt.token") + func `reads API key from preferred environment variable`() { + let env = ["KIMI_CODE_API_KEY": "kimi-code-key"] + #expect(KimiSettingsReader.apiKey(environment: env) == "kimi-code-key") } @Test - func `normalizes quoted token`() { - let env = ["KIMI_AUTH_TOKEN": "\"test.jwt.token\""] - let token = KimiSettingsReader.authToken(environment: env) - #expect(token == "test.jwt.token") + func `falls back to KIMI API key`() { + let env = ["KIMI_API_KEY": "\"kimi-api-key\""] + #expect(KimiSettingsReader.apiKey(environment: env) == "kimi-api-key") } @Test - func `returns nil when missing`() { - let env: [String: String] = [:] - let token = KimiSettingsReader.authToken(environment: env) - #expect(token == nil) + func `returns nil when API key missing`() { + #expect(KimiSettingsReader.apiKey(environment: [:]) == nil) } @Test - func `returns nil when empty`() { - let env = ["KIMI_AUTH_TOKEN": ""] - let token = KimiSettingsReader.authToken(environment: env) - #expect(token == nil) - } - - @Test - func `normalizes lowercase environment key`() { - let env = ["kimi_auth_token": "test.jwt.token"] - let token = KimiSettingsReader.authToken(environment: env) - #expect(token == "test.jwt.token") + func `builds credentials file URL under kimi home`() { + let env = ["KIMI_HOME": "/tmp/custom-kimi"] + let url = KimiSettingsReader.credentialsFileURL(environment: env) + #expect(url.path == "/tmp/custom-kimi/credentials/kimi-code.json") } } -struct KimiUsageResponseParsingTests { +struct KimiOAuthCredentialsTests { @Test - func `parses valid response`() throws { - let json = """ - { - "usages": [ - { - "scope": "FEATURE_CODING", - "detail": { - "limit": "2048", - "used": "375", - "remaining": "1673", - "resetTime": "2026-01-09T15:23:13.373329235Z" - }, - "limits": [ - { - "window": { - "duration": 300, - "timeUnit": "TIME_UNIT_MINUTE" - }, - "detail": { - "limit": "200", - "used": "200", - "resetTime": "2026-01-06T15:05:24.374187075Z" - } - } - ] - } - ] - } - """ + func `loads credentials from file`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("kimi-oauth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + let env = ["KIMI_HOME": home.path] - let response = try JSONDecoder().decode(KimiUsageResponse.self, from: Data(json.utf8)) + let credentials = KimiOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: Date().addingTimeInterval(3600).timeIntervalSince1970, + scope: "openid profile", + tokenType: "Bearer", + expiresIn: 3600) + try KimiOAuthCredentialsStore.save(credentials, env: env) - #expect(response.usages.count == 1) - let usage = response.usages[0] - #expect(usage.scope == "FEATURE_CODING") - #expect(usage.detail.limit == "2048") - #expect(usage.detail.used == "375") - #expect(usage.detail.remaining == "1673") - #expect(usage.detail.resetTime == "2026-01-09T15:23:13.373329235Z") + let loaded = try KimiOAuthCredentialsStore.load(env: env) + #expect(loaded == credentials) + } + + @Test + func `credentials need refresh near expiry`() { + let expiring = KimiOAuthCredentials( + accessToken: "token", + refreshToken: "refresh", + expiresAt: Date().addingTimeInterval(240).timeIntervalSince1970, + scope: "", + tokenType: "Bearer", + expiresIn: 3600) - #expect(usage.limits?.count == 1) - let rateLimit = usage.limits?.first - #expect(rateLimit?.window.duration == 300) - #expect(rateLimit?.window.timeUnit == "TIME_UNIT_MINUTE") - #expect(rateLimit?.detail.limit == "200") - #expect(rateLimit?.detail.used == "200") + #expect(expiring.needsRefresh == true) } +} +struct KimiUsageParsingTests { @Test - func `parses response without rate limits`() throws { + func `parses Kimi Code usage payload`() throws { let json = """ { - "usages": [ + "usage": { + "limit": "2048", + "used": "375", + "remaining": "1673", + "resetAt": "2026-01-09T15:23:13.373329235Z" + }, + "limits": [ { - "scope": "FEATURE_CODING", - "detail": { - "limit": "2048", - "used": "375", - "remaining": "1673", - "resetTime": "2026-01-09T15:23:13.373329235Z" + "window": { + "duration": 300, + "timeUnit": "TIME_UNIT_MINUTE" }, - "limits": [] + "detail": { + "limit": "200", + "used": "139", + "remaining": "61", + "resetAt": "2026-01-06T13:33:02.717479433Z" + } } ] } """ - let response = try JSONDecoder().decode(KimiUsageResponse.self, from: Data(json.utf8)) - #expect(response.usages.first?.limits?.isEmpty == true) + let payload = try KimiUsageFetcher._parsePayloadForTesting(Data(json.utf8)) + + #expect(payload.summary?.label == "Weekly limit") + #expect(payload.summary?.used == 375) + #expect(payload.summary?.limit == 2048) + #expect(payload.summary?.windowMinutes == nil) + #expect(payload.limits.count == 1) + #expect(payload.limits.first?.label == "5h limit") + #expect(payload.limits.first?.used == 139) + #expect(payload.limits.first?.limit == 200) + #expect(payload.limits.first?.windowMinutes == 300) } @Test - func `parses response with null limits`() throws { + func `parses limit from remaining when used is absent`() throws { let json = """ { - "usages": [ - { - "scope": "FEATURE_CODING", - "detail": { - "limit": "2048", - "used": "375", - "remaining": "1673", - "resetTime": "2026-01-09T15:23:13.373329235Z" - }, - "limits": null - } - ] + "usage": { + "limit": "2048", + "remaining": "2000" + }, + "limits": [] } """ - let response = try JSONDecoder().decode(KimiUsageResponse.self, from: Data(json.utf8)) - #expect(response.usages.first?.limits == nil) + let payload = try KimiUsageFetcher._parsePayloadForTesting(Data(json.utf8)) + #expect(payload.summary?.used == 48) } @Test - func `throws on invalid json`() { - let invalidJson = "{ invalid json }" + func `throws when no rows are present`() { + let json = #"{"usage": null, "limits": []}"# - #expect(throws: DecodingError.self) { - try JSONDecoder().decode(KimiUsageResponse.self, from: Data(invalidJson.utf8)) + #expect(throws: KimiAPIError.self) { + try KimiUsageFetcher._parsePayloadForTesting(Data(json.utf8)) } } - - @Test - func `throws on missing feature coding scope`() throws { - let json = """ - { - "usages": [ - { - "scope": "OTHER_SCOPE", - "detail": { - "limit": "100", - "used": "50", - "remaining": "50", - "resetTime": "2026-01-09T15:23:13.373329235Z" - } - } - ] - } - """ - - let response = try JSONDecoder().decode(KimiUsageResponse.self, from: Data(json.utf8)) - let codingUsage = response.usages.first { $0.scope == "FEATURE_CODING" } - #expect(codingUsage == nil) - } } struct KimiUsageSnapshotConversionTests { @Test - func `converts to usage snapshot with both windows`() { + func `converts summary and first two limits`() { let now = Date() - let weeklyDetail = KimiUsageDetail( - limit: "2048", - used: "375", - remaining: "1673", - resetTime: "2026-01-09T15:23:13.373329235Z") - let rateLimitDetail = KimiUsageDetail( - limit: "200", - used: "200", - remaining: "0", - resetTime: "2026-01-06T15:05:24.374187075Z") - let snapshot = KimiUsageSnapshot( - weekly: weeklyDetail, - rateLimit: rateLimitDetail, + summary: KimiUsageRow( + label: "Weekly limit", + used: 375, + limit: 2048, + windowMinutes: nil, + resetAt: "2026-01-09T15:23:13.373329235Z"), + limits: [ + KimiUsageRow( + label: "5h limit", + used: 139, + limit: 200, + windowMinutes: 300, + resetAt: "2026-01-06T13:33:02.717479433Z"), + KimiUsageRow( + label: "24h limit", + used: 80, + limit: 100, + windowMinutes: 1440, + resetAt: "2026-01-07T13:33:02.717479433Z"), + ], updatedAt: now) let usageSnapshot = snapshot.toUsageSnapshot() - #expect(usageSnapshot.primary != nil) - let weeklyExpected = 375.0 / 2048.0 * 100.0 - #expect(abs((usageSnapshot.primary?.usedPercent ?? 0.0) - weeklyExpected) < 0.01) - #expect(usageSnapshot.primary?.resetDescription == "375/2048 requests") + #expect(abs((usageSnapshot.primary?.usedPercent ?? 0) - (375.0 / 2048.0 * 100.0)) < 0.01) #expect(usageSnapshot.primary?.windowMinutes == nil) + #expect(usageSnapshot.primary?.resetDescription == "375/2048") - #expect(usageSnapshot.secondary != nil) - let rateExpected = 200.0 / 200.0 * 100.0 - #expect(abs((usageSnapshot.secondary?.usedPercent ?? 0.0) - rateExpected) < 0.01) - #expect(usageSnapshot.secondary?.windowMinutes == 300) // 5 hours - #expect(usageSnapshot.secondary?.resetDescription == "Rate: 200/200 per 5 hours") + #expect(abs((usageSnapshot.secondary?.usedPercent ?? 0) - 69.5) < 0.01) + #expect(usageSnapshot.secondary?.windowMinutes == 300) + #expect(usageSnapshot.secondary?.resetDescription == "5h limit: 139/200") - #expect(usageSnapshot.tertiary == nil) + #expect(usageSnapshot.tertiary?.windowMinutes == 1440) #expect(usageSnapshot.updatedAt == now) } - - @Test - func `converts to usage snapshot without rate limit`() { - let now = Date() - let weeklyDetail = KimiUsageDetail( - limit: "2048", - used: "375", - remaining: "1673", - resetTime: "2026-01-09T15:23:13.373329235Z") - - let snapshot = KimiUsageSnapshot( - weekly: weeklyDetail, - rateLimit: nil, - updatedAt: now) - - let usageSnapshot = snapshot.toUsageSnapshot() - - #expect(usageSnapshot.primary != nil) - let weeklyExpected = 375.0 / 2048.0 * 100.0 - #expect(abs((usageSnapshot.primary?.usedPercent ?? 0.0) - weeklyExpected) < 0.01) - #expect(usageSnapshot.secondary == nil) - #expect(usageSnapshot.tertiary == nil) - } - - @Test - func `handles zero values correctly`() { - let now = Date() - let weeklyDetail = KimiUsageDetail( - limit: "2048", - used: "0", - remaining: "2048", - resetTime: "2026-01-09T15:23:13.373329235Z") - - let snapshot = KimiUsageSnapshot( - weekly: weeklyDetail, - rateLimit: nil, - updatedAt: now) - - let usageSnapshot = snapshot.toUsageSnapshot() - #expect(usageSnapshot.primary?.usedPercent == 0.0) - } - - @Test - func `handles hundred percent correctly`() { - let now = Date() - let weeklyDetail = KimiUsageDetail( - limit: "2048", - used: "2048", - remaining: "0", - resetTime: "2026-01-09T15:23:13.373329235Z") - - let snapshot = KimiUsageSnapshot( - weekly: weeklyDetail, - rateLimit: nil, - updatedAt: now) - - let usageSnapshot = snapshot.toUsageSnapshot() - #expect(usageSnapshot.primary?.usedPercent == 100.0) - } } struct KimiTokenResolverTests { @Test - func `resolves token from environment`() { - KeychainAccessGate.withTaskOverrideForTesting(true) { - let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] - let token = ProviderTokenResolver.kimiAuthToken(environment: env) - #expect(token == "test.jwt.token") - } - } + func `resolves API key from environment`() { + let env = ["KIMI_API_KEY": "test-api-key"] + let resolution = ProviderTokenResolver.kimiAPIKeyResolution(environment: env) - @Test - func `resolves token from keychain first`() { - // This test would require mocking the keychain. - KeychainAccessGate.withTaskOverrideForTesting(true) { - let env = ["KIMI_AUTH_TOKEN": "test.env.token"] - let token = ProviderTokenResolver.kimiAuthToken(environment: env) - #expect(token == "test.env.token") - } - } - - @Test - func `resolution includes source`() { - KeychainAccessGate.withTaskOverrideForTesting(true) { - let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] - let resolution = ProviderTokenResolver.kimiAuthResolution(environment: env) - - #expect(resolution?.token == "test.jwt.token") - #expect(resolution?.source == .environment) - } + #expect(resolution?.token == "test-api-key") + #expect(resolution?.source == .environment) } } struct KimiAPIErrorTests { @Test func `error descriptions are helpful`() { - #expect(KimiAPIError.missingToken.errorDescription?.contains("missing") == true) - #expect(KimiAPIError.invalidToken.errorDescription?.contains("invalid") == true) + #expect(KimiAPIError.missingToken.errorDescription?.contains("credentials") == true) + #expect(KimiAPIError.invalidToken.errorDescription?.contains("expired") == true) #expect(KimiAPIError.invalidRequest("Bad request").errorDescription?.contains("Bad request") == true) #expect(KimiAPIError.networkError("Timeout").errorDescription?.contains("Timeout") == true) #expect(KimiAPIError.apiError("HTTP 500").errorDescription?.contains("HTTP 500") == true) diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 0b18ad89a..3f8fa15fe 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -194,7 +194,7 @@ struct SettingsStoreCoverageTests { settings.ensureFactoryCookieLoaded() settings.ensureMiniMaxCookieLoaded() settings.ensureMiniMaxAPITokenLoaded() - settings.ensureKimiAuthTokenLoaded() + settings.ensureKimiAPIKeyLoaded() settings.ensureKimiK2APITokenLoaded() settings.ensureAugmentCookieLoaded() settings.ensureAmpCookieLoaded() @@ -216,12 +216,11 @@ struct SettingsStoreCoverageTests { settings.codexCookieSource = .auto settings.claudeCookieSource = .auto - settings.kimiCookieSource = .off settings.debugDisableKeychainAccess = true #expect(settings.codexCookieSource == .manual) #expect(settings.claudeCookieSource == .manual) - #expect(settings.kimiCookieSource == .off) + #expect(settings.kimiUsageDataSource == .auto) } @Test diff --git a/docs/kimi.md b/docs/kimi.md index 4a3650e4c..4ded4364b 100644 --- a/docs/kimi.md +++ b/docs/kimi.md @@ -1,129 +1,64 @@ --- -summary: "Kimi provider notes: cookie auth, quotas, and rate-limit parsing." +summary: "Kimi Code provider notes: official CLI OAuth, optional API key, and /usages parsing." read_when: - Adding or modifying the Kimi provider - - Debugging Kimi cookie import or usage parsing - - Adjusting Kimi menu labels or settings + - Debugging Kimi Code auth or usage parsing + - Adjusting Kimi settings or labels --- -# Kimi Provider +# Kimi Code provider -Tracks usage for [Kimi For Coding](https://www.kimi.com/code) in CodexBar. +Tracks usage for [Kimi Code](https://www.kimi.com/code/) in CodexBar. -## Features - -- Displays weekly request quota (from membership tier) -- Shows current 5-hour rate limit usage -- Automatic and manual authentication methods -- Automatic refresh countdown - -## Setup - -Choose one of two authentication methods: - -### Method 1: Automatic Browser Import (Recommended) - -**No setup needed!** If you're already logged in to Kimi in Arc, Chrome, Safari, Edge, Brave, or Chromium: - -1. Open CodexBar settings → Providers → Kimi -2. Set "Cookie source" to "Automatic" -3. Enable the Kimi provider toggle -4. CodexBar will automatically find your session - -**Note**: Requires Full Disk Access to read browser cookies (System Settings → Privacy & Security → Full Disk Access → CodexBar). - -### Method 2: Manual Token Entry +This is a hard cutover from the older cookie-based `www.kimi.com/code` integration. The provider now targets the +official Kimi Code product and its `api.kimi.com/coding/v1` usage endpoint. -For advanced users or when automatic import fails: - -1. Open CodexBar settings → Providers → Kimi -2. Set "Cookie source" to "Manual" -3. Visit `https://www.kimi.com/code/console` in your browser -4. Open Developer Tools (F12 or Cmd+Option+I) -5. Go to **Application** → **Cookies** -6. Copy the `kimi-auth` cookie value (JWT token) -7. Paste it into the "Auth Token" field in CodexBar - -### Method 3: Environment Variable - -Alternatively, set the `KIMI_AUTH_TOKEN` environment variable: - -```bash -export KIMI_AUTH_TOKEN="jwt-token-here" -``` - -## Authentication Priority - -When multiple sources are available, CodexBar uses this order: - -1. Manual token (from Settings UI) -2. Environment variable (`KIMI_AUTH_TOKEN`) -3. Browser cookies (Arc → Chrome → Safari → Edge → Brave → Chromium) - -**Note**: Browser cookie import requires Full Disk Access permission. +## Features -## API Details +- Displays Kimi Code weekly quota usage +- Shows rolling rate-limit windows returned by the Kimi Code API +- Uses the official Kimi CLI OAuth session when available +- Supports an optional API key override for third-party coding-agent setups -**Endpoint**: `POST https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages` +## Data sources -**Authentication**: Bearer token (from `kimi-auth` cookie) +CodexBar supports two Kimi Code auth paths: -**Response**: -```json -{ - "usages": [{ - "scope": "FEATURE_CODING", - "detail": { - "limit": "2048", - "used": "214", - "remaining": "1834", - "resetTime": "2026-01-09T15:23:13.716839300Z" - }, - "limits": [{ - "window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"}, - "detail": { - "limit": "200", - "used": "139", - "remaining": "61", - "resetTime": "2026-01-06T13:33:02.717479433Z" - } - }] - }] -} -``` +1. **Kimi CLI OAuth** + - Reads `~/.kimi/credentials/kimi-code.json` + - Refreshes expired access tokens through `https://auth.kimi.com/api/oauth/token` + - This is the preferred source in `Auto` mode +2. **API key** + - Stored in `~/.codexbar/config.json` + - Or supplied via `KIMI_CODE_API_KEY` / `KIMI_API_KEY` -## Membership Tiers +## Usage endpoint -| Tier | Price | Weekly Quota | -|------|-------|--------------| -| Andante | ¥49/month | 1,024 requests | -| Moderato | ¥99/month | 2,048 requests | -| Allegretto | ¥199/month | 7,168 requests | +- `GET https://api.kimi.com/coding/v1/usages` +- `Authorization: Bearer ` -All tiers have a rate limit of 200 requests per 5 hours. +The payload contains: -## Troubleshooting +- top-level `usage` for the primary weekly quota row +- `limits[]` for rolling limit rows such as the 5-hour quota -### "Kimi auth token is missing" -- Ensure "Cookie source" is set correctly -- If using Automatic mode, verify you're logged in to Kimi in your browser -- Grant Full Disk Access permission if using browser cookies -- Try Manual mode and paste your token directly +CodexBar maps the summary row to the primary usage lane and the first two limit rows to secondary and tertiary lanes. -### "Kimi auth token is invalid or expired" -- Your token has expired. Paste a new token from your browser -- If using Automatic mode, log in to Kimi again in your browser +## Settings -### "No Kimi session cookies found" -- You're not logged in to Kimi in any supported browser -- Grant Full Disk Access to CodexBar in System Settings +Preferences → Providers → Kimi exposes: -### "Failed to parse Kimi usage data" -- The API response format may have changed. Please report this issue. +- **Usage source** + - `Auto`: prefer Kimi CLI OAuth, then fall back to API key + - `CLI OAuth`: only use `~/.kimi/credentials/kimi-code.json` + - `API Key`: only use the configured key +- **API key** + - Optional unless you want explicit API-key mode or a fallback when the CLI is not signed in -## Implementation +## Related files -- **Core files**: `Sources/CodexBarCore/Providers/Kimi/` -- **UI files**: `Sources/CodexBar/Providers/Kimi/` -- **Login flow**: `Sources/CodexBar/KimiLoginRunner.swift` -- **Tests**: `Tests/CodexBarTests/KimiProviderTests.swift` +- `Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift` +- `Sources/CodexBarCore/Providers/Kimi/KimiOAuthCredentials.swift` +- `Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift` +- `Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift` +- `Tests/CodexBarTests/KimiProviderTests.swift` diff --git a/docs/providers.md b/docs/providers.md index a82898e78..3182eae41 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -28,7 +28,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). | | z.ai | API token (Keychain/env) → quota API (`api`). | | MiniMax | Manual cookie header (Keychain/env) → browser cookies (+ local storage access token) → coding plan page (HTML) with remains API fallback (`web`). | -| Kimi | API token (JWT from `kimi-auth` cookie) → usage API (`api`). | +| Kimi | Kimi CLI OAuth → Kimi Code usage API (`oauth`); optional API key fallback (`api`). | | Kilo | API token (`KILO_API_KEY`) → usage API (`api`); auto falls back to CLI session auth (`cli`). | | Copilot | API token (device flow/env) → copilot_internal API (`api`). | | Kimi K2 | API key (Keychain/env) → credit endpoint (`api`). | @@ -71,9 +71,10 @@ until the session is invalid, to avoid repeated Keychain prompts. - Details: `docs/minimax.md`. ## Kimi -- Auth token (JWT from `kimi-auth` cookie) via manual entry or `KIMI_AUTH_TOKEN` env var. -- `POST https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages`. -- Shows weekly quota and 5-hour rate limit (300 minutes). +- Official Kimi CLI OAuth from `~/.kimi/credentials/kimi-code.json`, with refresh through `auth.kimi.com`. +- Optional API key via Settings, `KIMI_CODE_API_KEY`, or `KIMI_API_KEY`. +- `GET https://api.kimi.com/coding/v1/usages`. +- Shows the primary weekly quota plus rolling limits returned by Kimi Code. - Status: none yet. - Details: `docs/kimi.md`.