diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 42e83c2e24..0f41ddf8e9 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -478,8 +478,13 @@ strip_release_binary "$APP/Contents/Helpers/CodexBarCLI" # Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed. install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog" strip_release_binary "$APP/Contents/Helpers/CodexBarClaudeWatchdog" -install_widget_extension -strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget" +if [[ "${CODEXBAR_SKIP_WIDGET:-0}" == "1" ]]; then + echo "WARN: Skipping CodexBarWidget extension (CODEXBAR_SKIP_WIDGET=1)." >&2 + rm -rf "$APP/Contents/PlugIns/CodexBarWidget.appex" +else + install_widget_extension + strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget" +fi swiftpm_bin_path "${ARCH_LIST[0]}" PREFERRED_BUILD_DIR diff --git a/Sources/CodexBar/MenuHighlightStyle.swift b/Sources/CodexBar/MenuHighlightStyle.swift index bb493b5026..4028f7059d 100644 --- a/Sources/CodexBar/MenuHighlightStyle.swift +++ b/Sources/CodexBar/MenuHighlightStyle.swift @@ -1,11 +1,28 @@ import SwiftUI +// Manual EnvironmentKey (instead of @Entry) so the app builds with Command Line Tools +// without SwiftUIMacros / full Xcode. +private struct MenuItemHighlightedKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +private struct MenuCardRefreshMonitorKey: EnvironmentKey { + static let defaultValue: MenuCardRefreshMonitor? = nil +} + extension EnvironmentValues { - @Entry var menuItemHighlighted: Bool = false + var menuItemHighlighted: Bool { + get { self[MenuItemHighlightedKey.self] } + set { self[MenuItemHighlightedKey.self] = newValue } + } + /// Optional live-refresh monitor injected into menu card views so the provider card /// subtitle can reflect the in-flight "Refreshing…" state in place while the NSMenu /// stays open, without rebuilding the menu during AppKit tracking. - @Entry var menuCardRefreshMonitor: MenuCardRefreshMonitor? + var menuCardRefreshMonitor: MenuCardRefreshMonitor? { + get { self[MenuCardRefreshMonitorKey.self] } + set { self[MenuCardRefreshMonitorKey.self] = newValue } + } } enum MenuHighlightStyle { diff --git a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift index ca6be08aeb..f8d2fc5b63 100644 --- a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift @@ -75,8 +75,10 @@ struct KimiProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "kimi-usage-source", title: "Usage source", - subtitle: "Auto tries your configured API key, then a signed-in Kimi Code CLI credential, " + - "then browser cookies.", + subtitle: "Tracks the Kimi Code subscription only (api.kimi.com weekly quota). " + + "Auto tries your Code API key, then a signed-in Kimi Code CLI credential, then browser cookies. " + + "China open-platform balance (api.moonshot.cn) is a different product — enable " + + "Moonshot / Kimi Open Platform and set API region to China mainland.", binding: usageBinding, options: usageOptions, isVisible: nil, @@ -89,7 +91,7 @@ struct KimiProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "kimi-cookie-source", title: "Cookie source", - subtitle: "Automatic imports browser cookies.", + subtitle: "Automatic imports browser cookies from kimi.com (Code console).", dynamicSubtitle: subtitle, binding: cookieBinding, options: options, @@ -103,15 +105,17 @@ struct KimiProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "kimi-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. You can also provide KIMI_CODE_API_KEY.", + title: "Kimi Code API key", + subtitle: "Code subscription key from www.kimi.com/code (not platform.kimi.com open-platform keys). " + + "Stored in ~/.codexbar/config.json. You can also provide KIMI_CODE_API_KEY. " + + "For China open-platform balance, use Moonshot / Kimi Open Platform instead.", kind: .secure, placeholder: "Paste Kimi Code API key...", binding: context.stringBinding(\.kimiAPIKey), actions: [ ProviderSettingsActionDescriptor( id: "kimi-open-api-docs", - title: "Open API docs", + title: "Open Code docs", style: .link, isVisible: nil, perform: { @@ -119,6 +123,17 @@ struct KimiProviderImplementation: ProviderImplementation { NSWorkspace.shared.open(url) } }), + ProviderSettingsActionDescriptor( + id: "kimi-open-open-platform-china", + title: "China open platform", + style: .link, + isVisible: nil, + perform: { + // Jump to the product that actually has a China API host. + if let url = URL(string: "https://platform.kimi.com/console/account") { + NSWorkspace.shared.open(url) + } + }), ], isVisible: nil, onActivate: nil), @@ -132,7 +147,7 @@ struct KimiProviderImplementation: ProviderImplementation { actions: [ ProviderSettingsActionDescriptor( id: "kimi-open-console", - title: "Open Console", + title: "Open Code console", style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift b/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift index c96088fd24..e18cda20fc 100644 --- a/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift @@ -49,7 +49,10 @@ struct MoonshotProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "moonshot-api-region", title: "API region", - subtitle: "Choose the Moonshot/Kimi API host for international or China mainland accounts.", + subtitle: "Open-platform pay-as-you-go balance only. " + + "China mainland uses api.moonshot.cn (platform.kimi.com keys). " + + "International uses api.moonshot.ai. " + + "Kimi Code weekly subscription is a different product under Kimi Code.", binding: binding, options: options, isVisible: nil, @@ -63,20 +66,19 @@ struct MoonshotProviderImplementation: ProviderImplementation { ProviderSettingsFieldDescriptor( id: "moonshot-api-key", title: "API key", - subtitle: "Stored in ~/.codexbar/config.json.", + subtitle: "Open-platform key for the selected region. Stored in ~/.codexbar/config.json " + + "(or MOONSHOT_API_KEY). Do not paste a Kimi Code subscription key here.", kind: .secure, placeholder: "sk-...", binding: context.stringBinding(\.moonshotAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "moonshot-open-dashboard", - title: "Open Moonshot Console", + title: "Open console", style: .link, isVisible: nil, perform: { - if let url = URL(string: "https://platform.moonshot.ai/console/account") { - NSWorkspace.shared.open(url) - } + NSWorkspace.shared.open(context.settings.moonshotRegion.consoleURL) }), ], isVisible: nil, diff --git a/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift b/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift index adeece16f7..fd86258902 100644 --- a/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift @@ -45,7 +45,8 @@ struct ZaiProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "zai-api-region", title: "API region", - subtitle: "Use BigModel for the China mainland endpoints (open.bigmodel.cn).", + subtitle: "Global uses api.z.ai. China mainland GLM Coding Plan uses open.bigmodel.cn " + + "(BigModel keys from bigmodel.cn — not interchangeable with global z.ai keys).", binding: binding, options: options, isVisible: nil, @@ -54,7 +55,41 @@ struct ZaiProviderImplementation: ProviderImplementation { } @MainActor - func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - [] + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "zai-api-key", + title: "API key", + subtitle: "Coding Plan key (5-hour token window). China: BigModel open.bigmodel.cn key + " + + "region BigModel CN. Also auto-reads ~/.coding-relay/glm-api-key or " + + "Z_AI_API_KEY / BIGMODEL_API_KEY / ZHIPU_API_KEY / GLM_API_KEY.", + kind: .secure, + placeholder: "Paste Coding Plan API key…", + binding: context.stringBinding(\.zaiAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "zai-open-bigmodel-keys", + title: "BigModel keys", + style: .link, + isVisible: { context.settings.zaiAPIRegion == .bigmodelCN }, + perform: { + if let url = URL(string: "https://bigmodel.cn/usercenter/proj-mgmt/apikeys") { + NSWorkspace.shared.open(url) + } + }), + ProviderSettingsActionDescriptor( + id: "zai-open-global-console", + title: "z.ai console", + style: .link, + isVisible: { context.settings.zaiAPIRegion == .global }, + perform: { + if let url = URL(string: "https://z.ai/manage-apikey/apikey") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: { context.settings.ensureZaiAPITokenLoaded() }), + ] } } diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 5cc6b2defb..708fbef5ce 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -574,7 +574,8 @@ "usage_bars_fill_used" = "As used"; "reset_times_title" = "Reset times"; "reset_times_countdown" = "Countdown"; -"reset_times_clock" = "Clock time"; +"reset_times_clock" = "Date only"; +"reset_times_date" = "Date only"; "cost_summary_title" = "Cost summary"; "cost_summary_off" = "Off"; "merge_icons_title" = "Merge icons"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index ae94fdb4b8..3521b9c0a2 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -546,7 +546,8 @@ "usage_bars_fill_used" = "按已用量"; "reset_times_title" = "重置时间"; "reset_times_countdown" = "倒计时"; -"reset_times_clock" = "时钟时间"; +"reset_times_clock" = "仅日期"; +"reset_times_date" = "仅日期"; "cost_summary_title" = "费用摘要"; "cost_summary_off" = "关闭"; "merge_icons_title" = "合并图标"; diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 7f28ffa86f..166dd730a7 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -46,7 +46,8 @@ enum ResetTimesOption: String, CaseIterable { var label: String { switch self { case .countdown: L("reset_times_countdown") - case .clock: L("reset_times_clock") + // Absolute style is date-only (no hour:minute). + case .clock: L("reset_times_date") } } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift index b3f6deef69..065939f911 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift @@ -94,10 +94,17 @@ public enum KimiCookieImporter { return first } + /// Prefer official Kimi Desktop session when browsers are not signed in. + public static func desktopAuthToken() -> String? { + KimiDesktopAuthToken.load() + } + public static func hasSession( browserDetection: BrowserDetection = BrowserDetection(), logger: ((String) -> Void)? = nil) -> Bool { + if self.desktopAuthToken() != nil { return true } + do { return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty } catch { diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiDesktopAuthToken.swift b/Sources/CodexBarCore/Providers/Kimi/KimiDesktopAuthToken.swift new file mode 100644 index 0000000000..713112cf48 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kimi/KimiDesktopAuthToken.swift @@ -0,0 +1,89 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Reads the `kimi-auth` web session token from the official Kimi Desktop Electron app. +/// +/// Kimi Code CLI tokens can fetch weekly/rate-limit usage from `api.kimi.com`, but the +/// **Monthly** subscription pool still comes from the web membership API and needs a +/// `kimi-auth` cookie. Users who only run the CLI (no browser login) often still have +/// Kimi Desktop signed in — its Cookies DB stores `kimi-auth` in plaintext. +public enum KimiDesktopAuthToken: Sendable { + private static let log = CodexBarLog.logger(LogCategories.kimiCookie) + + public static func cookiesDatabaseURL( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL + { + homeDirectory + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("kimi-desktop", isDirectory: true) + .appendingPathComponent("Cookies", isDirectory: false) + } + + /// Returns a non-empty `kimi-auth` value, or nil when missing/unreadable. + public static func load( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> String? + { + #if os(macOS) + let dbURL = self.cookiesDatabaseURL(homeDirectory: homeDirectory) + guard FileManager.default.isReadableFile(atPath: dbURL.path) else { return nil } + + // Chrome/Electron may hold a write lock; copy to a temp file first. + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kimi-desktop-cookies-\(UUID().uuidString).db") + do { + try FileManager.default.copyItem(at: dbURL, to: tempURL) + } catch { + Self.log.debug("Kimi Desktop Cookies copy failed: \(error.localizedDescription)") + return nil + } + defer { try? FileManager.default.removeItem(at: tempURL) } + + guard let token = self.readKimiAuth(fromSQLitePath: tempURL.path) else { return nil } + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + #else + return nil + #endif + } + + private static func readKimiAuth(fromSQLitePath path: String) -> String? { + // Minimal SQLite3 open without a package dependency — Cookies is a standard Chromium DB. + var db: OpaquePointer? + guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + return nil + } + defer { sqlite3_close(db) } + + let sql = """ + SELECT value, length(encrypted_value) + FROM cookies + WHERE name = 'kimi-auth' + AND (host_key = 'www.kimi.com' OR host_key = '.www.kimi.com' OR host_key = '.kimi.com' OR host_key = 'kimi.com') + ORDER BY last_access_utc DESC + LIMIT 1; + """ + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + return nil + } + defer { sqlite3_finalize(statement) } + + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + if let cString = sqlite3_column_text(statement, 0) { + let value = String(cString: cString) + if !value.isEmpty { return value } + } + // Encrypted-only rows need Keychain AES — leave those to BrowserCookieClient. + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index 14d93764e8..89353f7933 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -8,14 +8,14 @@ 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 subscription usage", cliName: "kimi", defaultEnabled: false, isPrimaryProvider: false, @@ -34,7 +34,7 @@ public enum KimiProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Kimi cost summary is not supported." }), + noDataMessage: { "Kimi Code cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api, .web], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), @@ -179,6 +179,7 @@ struct KimiWebFetchStrategy: ProviderFetchStrategy { #if os(macOS) if context.settings?.kimi?.cookieSource != .off { + if KimiDesktopAuthToken.load() != nil { return true } return KimiCookieImporter.hasSession() } #endif @@ -192,9 +193,20 @@ struct KimiWebFetchStrategy: ProviderFetchStrategy { } let snapshot = try await KimiUsageFetcher.fetchUsage(authToken: token) + let sourceLabel = self.sourceLabel(for: context, token: token) return self.makeResult( usage: snapshot.toUsageSnapshot(), - sourceLabel: "Kimi web cookie") + sourceLabel: sourceLabel) + } + + private func sourceLabel(for context: ProviderFetchContext, token: String) -> String { + if KimiCookieHeader.resolveCookieOverride(context: context) != nil { + return "Kimi web cookie" + } + if KimiDesktopAuthToken.load() == token { + return "Kimi Desktop" + } + return "Kimi web cookie" } func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { @@ -213,9 +225,12 @@ struct KimiWebFetchStrategy: ProviderFetchStrategy { return override.token } - // Try browser cookie import when auto mode is enabled + // Prefer signed-in Kimi Desktop (plaintext kimi-auth) before browser Keychain import. #if os(macOS) if context.settings?.kimi?.cookieSource != .off { + if let desktop = KimiDesktopAuthToken.load() { + return desktop + } do { let session = try KimiCookieImporter.importSession() if let token = session.authToken { diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift index eab5005ea7..ac7835d300 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift @@ -44,7 +44,51 @@ public struct KimiUsageFetcher: Sendable { throw self.codeAPIError(statusCode: response.statusCode) } - return try self.parseCodeAPIUsage(from: data, now: now) + let base = try self.parseCodeAPIUsage(from: data, now: now) + // Monthly subscription pool lives on the web membership API (kimi-auth cookie), + // not on /coding/v1/usages. Enrich best-effort when a web session is available + // (Kimi Desktop cookies, browser import, or KIMI_AUTH_TOKEN). + return await self.enrichingWithSubscriptionStats(base, now: now, transport: transport) + } + + /// Best-effort Monthly (+ Code 7-day) enrichment for Code API / CLI snapshots. + private static func enrichingWithSubscriptionStats( + _ snapshot: KimiUsageSnapshot, + now: Date, + transport: any ProviderHTTPTransport) async -> KimiUsageSnapshot + { + guard let webToken = self.resolveWebAuthTokenForEnrichment() else { + return snapshot + } + let sessionInfo = self.decodeSessionInfo(from: webToken) + guard let stats = try? await self.fetchSubscriptionStats( + authToken: webToken, + sessionInfo: sessionInfo, + transport: transport) + else { + return snapshot + } + return KimiUsageSnapshot( + weekly: snapshot.weekly, + rateLimit: snapshot.rateLimit, + subscriptionBalance: stats.subscriptionBalance, + subscriptionCodeWeeklyLimit: stats.ratelimitCode7d, + updatedAt: now) + } + + private static func resolveWebAuthTokenForEnrichment() -> String? { + if let token = KimiSettingsReader.authToken() { + return token + } + if let token = KimiDesktopAuthToken.load() { + return token + } + #if os(macOS) + if let token = try? KimiCookieImporter.importSession().authToken { + return token + } + #endif + return nil } static func _parseCodeAPIUsageForTesting(_ data: Data, now: Date = Date()) throws -> KimiUsageSnapshot { diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift index fc18f20391..7072882d87 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -8,19 +8,20 @@ public enum MoonshotProviderDescriptor { id: .moonshot, metadata: ProviderMetadata( id: .moonshot, - displayName: "Moonshot / Kimi API", + displayName: "Moonshot / Kimi Open Platform", sessionLabel: "Balance", weeklyLabel: "Balance", opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Moonshot / Kimi API balance", + toggleTitle: "Show Moonshot / Kimi open-platform balance", cliName: "moonshot", defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, browserCookieOrder: nil, + // International console by default; China uses platform.kimi.com via region-aware UI actions. dashboardURL: "https://platform.moonshot.ai/console/account", statusPageURL: nil), branding: ProviderBranding( @@ -34,7 +35,7 @@ public enum MoonshotProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Moonshot / Kimi API cost summary is not available." }), + noDataMessage: { "Moonshot / Kimi open-platform cost summary is not available." }), fetchPlan: .apiToken( strategyID: "moonshot.api", resolveToken: { ProviderTokenResolver.moonshotToken(environment: $0) }, @@ -48,7 +49,8 @@ public enum MoonshotProviderDescriptor { }), cli: ProviderCLIConfig( name: "moonshot", - aliases: [], + // Common aliases when users look for “Kimi China / open platform”. + aliases: ["kimi-open", "kimi-cn", "moonshot-cn"], versionDetector: nil)) } } diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift index 420e3ff1e7..0020ef02d1 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift @@ -9,9 +9,9 @@ public enum MoonshotRegion: String, CaseIterable, Sendable { public var displayName: String { switch self { case .international: - "International (api.moonshot.ai)" + "International · api.moonshot.ai" case .china: - "China (api.moonshot.cn)" + "China mainland · api.moonshot.cn" } } @@ -24,6 +24,17 @@ public enum MoonshotRegion: String, CaseIterable, Sendable { } } + /// Console where keys are issued for this region. + public var consoleURL: URL { + switch self { + case .international: + URL(string: "https://platform.moonshot.ai/console/account")! + case .china: + // China open platform is also branded as platform.kimi.com. + URL(string: "https://platform.kimi.com/console/account")! + } + } + public var balanceURL: URL { URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.balancePath) } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift index 4c939be3fb..dceb1d1b50 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift @@ -10,9 +10,9 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { public var displayName: String { switch self { case .global: - "Global (api.z.ai)" + "Global · api.z.ai" case .bigmodelCN: - "BigModel CN (open.bigmodel.cn)" + "China mainland · open.bigmodel.cn (BigModel)" } } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index 13995d248e..879fef19fd 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -8,14 +8,15 @@ public enum ZaiProviderDescriptor { id: .zai, metadata: ProviderMetadata( id: .zai, - displayName: "z.ai", - sessionLabel: "Tokens", - weeklyLabel: "MCP", + displayName: "z.ai / GLM", + // Coding Plan: primary bar is the 5-hour token window. + sessionLabel: "5-hour", + weeklyLabel: "Weekly", opusLabel: "5-hour", supportsOpus: true, supportsCredits: false, creditsHint: "", - toggleTitle: "Show z.ai usage", + toggleTitle: "Show z.ai / GLM usage", cliName: "zai", defaultEnabled: false, isPrimaryProvider: false, diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift index 5f92a201a9..d8cfe84798 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift @@ -4,15 +4,48 @@ public struct ZaiSettingsReader: Sendable { private static let log = CodexBarLog.logger(LogCategories.zaiSettings) public static let apiTokenKey = "Z_AI_API_KEY" + /// Aliases commonly used for BigModel / 智谱 China coding-plan keys. + public static let apiTokenEnvironmentKeys = [ + "Z_AI_API_KEY", + "BIGMODEL_API_KEY", + "ZHIPU_API_KEY", + "ZHIPUAI_API_KEY", + "ZAI_API_KEY", + "GLM_API_KEY", + ] + /// Local one-line key files used by coding relays / scripts on this machine. + public static let localAPIKeyRelativePaths = [ + ".coding-relay/glm-api-key", + ".config/bigmodel/api_key", + ".config/zhipu/api_key", + ] public static let apiHostKey = "Z_AI_API_HOST" public static let quotaURLKey = "Z_AI_QUOTA_URL" public static let bigModelOrganizationKey = "Z_AI_BIGMODEL_ORGANIZATION" public static let bigModelProjectKey = "Z_AI_BIGMODEL_PROJECT" public static func apiToken( - environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> String? + { + for key in self.apiTokenEnvironmentKeys { + if let token = self.cleaned(environment[key]) { return token } + } + return self.localAPIKeyFileToken(homeDirectory: homeDirectory) + } + + /// Reads a single-line API key from well-known home-relative paths (e.g. coding-relay). + public static func localAPIKeyFileToken( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> String? { - if let token = self.cleaned(environment[apiTokenKey]) { return token } + for relative in self.localAPIKeyRelativePaths { + let url = homeDirectory.appendingPathComponent(relative) + guard FileManager.default.isReadableFile(atPath: url.path), + let raw = try? String(contentsOf: url, encoding: .utf8), + let token = self.cleaned(raw.split(separator: "\n").first.map(String.init)) + else { continue } + return token + } return nil } @@ -81,7 +114,9 @@ public enum ZaiSettingsError: LocalizedError, Sendable, Equatable { public var errorDescription: String? { switch self { case .missingToken: - "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." + "z.ai / GLM Coding Plan key not found. Enable z.ai / GLM, paste a BigModel key, set " + + "Z_AI_API_KEY / BIGMODEL_API_KEY / ZHIPU_API_KEY / GLM_API_KEY, or place a one-line key at " + + "~/.coding-relay/glm-api-key." case let .invalidEndpointOverride(key): "z.ai endpoint override \(key) must use HTTPS or a bare host." } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 3443271967..14bbfa2c2e 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -194,15 +194,42 @@ public struct ZaiUsageSnapshot: Sendable { extension ZaiUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let primaryLimit = self.tokenLimit ?? self.timeLimit - let secondaryLimit = (self.tokenLimit != nil && self.timeLimit != nil) ? self.timeLimit : nil + // Coding Plan product surface is the rolling token window (almost always 5h TOKENS_LIMIT). + // TIME_LIMIT with MCP usageDetails is tool/MCP quota — not the coding 5h budget. + let codingTokenLimit = self.sessionTokenLimit ?? self.tokenLimit + let longerTokenLimit: ZaiLimitEntry? = { + guard let session = self.sessionTokenLimit, let weekly = self.tokenLimit, + session.windowMinutes != weekly.windowMinutes + else { return nil } + return weekly + }() + + let primaryLimit = codingTokenLimit ?? self.timeLimit let primary = primaryLimit.map { Self.rateWindow(for: $0) } ?? RateWindow( usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + + // Prefer a second token window (e.g. weekly) over MCP when both exist. + let secondaryLimit = longerTokenLimit ?? { + guard codingTokenLimit != nil, let time = self.timeLimit, !time.isMCPMonthlyMarker else { + // Still surface non-MCP time windows if that is all we have besides tokens. + guard codingTokenLimit != nil, let time = self.timeLimit else { return nil } + return time.isMCPMonthlyMarker ? nil : time + } + return nil + }() let secondary = secondaryLimit.map { Self.rateWindow(for: $0) } - let tertiary = self.sessionTokenLimit.map { Self.rateWindow(for: $0) } + + // MCP tool quota is advisory for Coding Plan — keep it off primary/secondary bars. + let mcpExtra: NamedRateWindow? = { + guard let time = self.timeLimit, time.isMCPMonthlyMarker else { return nil } + return NamedRateWindow( + id: "zai-mcp", + title: "MCP", + window: Self.rateWindow(for: time)) + }() let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -214,7 +241,8 @@ extension ZaiUsageSnapshot { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: tertiary, + tertiary: nil, + extraRateWindows: mcpExtra.map { [$0] }, providerCost: nil, zaiUsage: self, updatedAt: self.updatedAt, @@ -224,6 +252,7 @@ extension ZaiUsageSnapshot { private static func rateWindow(for limit: ZaiLimitEntry) -> RateWindow { RateWindow( usedPercent: limit.usedPercent, + // Coding Plan token windows (5h) carry duration; MCP markers do not. windowMinutes: limit.type == .tokensLimit ? limit.windowMinutes : nil, resetsAt: limit.nextResetTime, resetDescription: self.resetDescription(for: limit)) @@ -231,13 +260,16 @@ extension ZaiUsageSnapshot { private static func resetDescription(for limit: ZaiLimitEntry) -> String? { if limit.isMCPMonthlyMarker { - return "Monthly" + return "MCP" + } + if limit.type == .tokensLimit, let minutes = limit.windowMinutes, minutes == 5 * 60 { + return "5-hour" } if let label = limit.windowLabel { return label } if limit.type == .timeLimit { - return "Monthly" + return "MCP" } return nil } @@ -268,11 +300,13 @@ private struct ZaiQuotaLimitData: Decodable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.limits = try container.decodeIfPresent([ZaiLimitRaw].self, forKey: .limits) ?? [] + // BigModel Coding Plan often returns `level` (e.g. "max") instead of planName. let rawPlan = try [ container.decodeIfPresent(String.self, forKey: .planName), container.decodeIfPresent(String.self, forKey: .plan), container.decodeIfPresent(String.self, forKey: .planType), container.decodeIfPresent(String.self, forKey: .packageName), + container.decodeIfPresent(String.self, forKey: .level), ].compactMap(\.self).first let trimmed = rawPlan?.trimmingCharacters(in: .whitespacesAndNewlines) self.planName = (trimmed?.isEmpty ?? true) ? nil : trimmed @@ -284,6 +318,7 @@ private struct ZaiQuotaLimitData: Decodable { case plan case planType = "plan_type" case packageName + case level } } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index c8674fab5f..e12236db9b 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -67,6 +67,8 @@ public enum UsageFormatter { case "usage_percent_suffix_left": return "left" case "usage_percent_suffix_used": return "used" case "reset_tomorrow_format": return "tomorrow, %@" + case "Today": return "Today" + case "Tomorrow": return "Tomorrow" case "byte_unit_byte": return "byte" case "byte_unit_bytes": return "bytes" case "byte_unit_kilobyte": return "kilobyte" @@ -128,18 +130,18 @@ public enum UsageFormatter { } public static func resetDescription(from date: Date, now: Date = .init()) -> String { - // Human-friendly phrasing: today / tomorrow / date+time. + // Date only (no clock time): users asked for calendar day without hour:minute noise. let calendar = Calendar.current if calendar.isDate(date, inSameDayAs: now) { - return date.formatted(.dateTime.hour().minute().locale(self.currentLocale())) + return self.localized("Today") } if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(date, inSameDayAs: tomorrow) { - let timeStr = date.formatted(.dateTime.hour().minute().locale(self.currentLocale())) - return self.localized("reset_tomorrow_format", timeStr) + return self.localized("Tomorrow") } - return date.formatted(.dateTime.month(.abbreviated).day().hour().minute().locale(self.currentLocale())) + // e.g. "Jul 24" / "7月24日" depending on locale — never hour/minute. + return date.formatted(.dateTime.month(.abbreviated).day().locale(self.currentLocale())) } public static func resetLine( diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 6f25cd218d..d57c02c7fd 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -39,7 +39,7 @@ enum ProviderChoice: String, AppEnum { .opencode: DisplayRepresentation(title: "OpenCode"), .opencodego: DisplayRepresentation(title: "OpenCode Go"), .mistral: DisplayRepresentation(title: "Mistral"), - .kimi: DisplayRepresentation(title: "Kimi"), + .kimi: DisplayRepresentation(title: "Kimi Code"), ] var provider: UsageProvider { diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 51e22c34d0..b424d16a4a 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -2,8 +2,16 @@ import CodexBarCore import SwiftUI import WidgetKit +// Manual EnvironmentKey (instead of @Entry) so the widget builds without SwiftUIMacros / full Xcode. +private struct WidgetUsageShowsUsedKey: EnvironmentKey { + static let defaultValue: Bool = false +} + extension EnvironmentValues { - @Entry fileprivate var widgetUsageShowsUsed: Bool = false + fileprivate var widgetUsageShowsUsed: Bool { + get { self[WidgetUsageShowsUsedKey.self] } + set { self[WidgetUsageShowsUsedKey.self] = newValue } + } } struct CodexBarUsageWidgetView: View { @@ -313,7 +321,7 @@ private struct ProviderSwitchChip: View { case .opencodego: "OpenCode Go" case .alibaba: "Alibaba" case .alibabatokenplan: "Token Plan" - case .zai: "z.ai" + case .zai: "GLM" case .factory: "Droid" case .copilot: "Copilot" case .devin: "Devin" @@ -324,7 +332,7 @@ private struct ProviderSwitchChip: View { case .kiro: "Kiro" case .augment: "Augment" case .jetbrains: "JetBrains" - case .kimi: "Kimi" + case .kimi: "Kimi Code" case .moonshot: "Moonshot" case .amp: "Amp" case .t3chat: "T3 Chat" diff --git a/docs/kimi.md b/docs/kimi.md index fa1cc5a8c0..d246f77633 100644 --- a/docs/kimi.md +++ b/docs/kimi.md @@ -6,17 +6,47 @@ read_when: - Adjusting Kimi menu labels or settings --- -# Kimi Provider +# Kimi Code Provider -Tracks usage for [Kimi For Coding](https://www.kimi.com/code) in CodexBar. +Tracks **Kimi Code subscription** usage for [Kimi For Coding](https://www.kimi.com/code) in CodexBar +(`api.kimi.com` weekly quota + rate limits). + +## Not the China open platform + +Kimi has two official products that are easy to confuse: + +| Product | Host | What CodexBar shows | Provider id | +| --- | --- | --- | --- | +| **Kimi Code** (this page) | `api.kimi.com` / `www.kimi.com` | Weekly coding quota | `kimi` | +| **Moonshot / Kimi open platform** | `api.moonshot.cn` (China) or `api.moonshot.ai` (intl) | Cash/voucher **balance** | `moonshot` | + +There is **no** separate Kimi Code usage host for China (`api.moonshot.cn/coding/v1/usages` is 404). +If you need mainland open-platform balance, enable **Moonshot / Kimi Open Platform** and set +API region to **China mainland**. See [moonshot.md](moonshot.md). ## Features - Displays weekly request quota (from membership tier) - Shows current 5-hour rate limit usage +- **Monthly** subscription pool when a web `kimi-auth` session is available - API-key, Kimi Code CLI, automatic cookie, and manual cookie authentication methods - Automatic refresh countdown +### Monthly quota enrichment (CLI / API key mode) + +`GET https://api.kimi.com/coding/v1/usages` returns weekly + short rate limits only. +The **Monthly** pool still comes from the web membership API +(`GetSubscriptionStats`), which needs a `kimi-auth` cookie. + +When usage is fetched via Code API key or Kimi Code CLI, CodexBar best-effort enriches +with membership stats using (in order): + +1. `KIMI_AUTH_TOKEN` / manual cookie +2. **Kimi Desktop** app cookie DB (`~/Library/Application Support/kimi-desktop/Cookies`) +3. Browser cookie import (existing path) + +If no web session is found, Weekly / Rate Limit still work; Monthly is omitted. + ## Setup Choose one of four authentication methods: diff --git a/docs/moonshot.md b/docs/moonshot.md index 05500c8857..c974904b9a 100644 --- a/docs/moonshot.md +++ b/docs/moonshot.md @@ -1,33 +1,41 @@ --- -summary: "Moonshot / Kimi API provider data sources: API key + balance endpoint." +summary: "Moonshot / Kimi open-platform balance: API key + China/intl region." read_when: - Adding or tweaking Moonshot balance parsing - - Updating Moonshot / Kimi API key handling - - Documenting Moonshot / Kimi API provider behavior + - Updating Moonshot / Kimi open-platform key handling + - Documenting China vs international Kimi API hosts --- -# Moonshot / Kimi API provider +# Moonshot / Kimi Open Platform provider -Moonshot / Kimi API is API-only. Balance is reported by `GET /v1/users/me/balance`, -so CodexBar only needs a valid API key to show the current account balance. +Tracks **open-platform pay-as-you-go balance** via `GET /v1/users/me/balance`. +This is the product with a real **China mainland** API host (`api.moonshot.cn`). -## Rationale +## Not Kimi Code subscription + +| Product | Host | CodexBar provider | +| --- | --- | --- | +| **Open platform (this page)** | `api.moonshot.cn` / `api.moonshot.ai` | `moonshot` | +| **Kimi Code weekly quota** | `api.kimi.com` only | `kimi` — see [kimi.md](kimi.md) | + +Do not paste a Kimi Code subscription key into this provider. Keys from +`platform.kimi.com` (China) or `platform.moonshot.ai` (intl) belong here. -Kimi API docs use the Moonshot API surface for current Kimi models: examples read -`MOONSHOT_API_KEY` and call `https://api.moonshot.ai/v1`, including the Kimi K2.6 -quickstart. This provider is therefore named after the account and billing surface, -not a specific Kimi model version. +CLI aliases: `moonshot`, `kimi-open`, `kimi-cn`, `moonshot-cn`. + +## Rationale -CodexBar uses the official Moonshot account and billing surface rather than unofficial -third-party Kimi relays. +Kimi open-platform docs use the Moonshot API surface: examples read `MOONSHOT_API_KEY` +and call `https://api.moonshot.ai/v1` (global) or `https://api.moonshot.cn/v1` (China). +CodexBar names this after the billing surface, not a single model version. ## Data sources 1. **API key** stored in `~/.codexbar/config.json` or supplied via `MOONSHOT_API_KEY` / `MOONSHOT_KEY`. - CodexBar stores the key in config after you paste it in Settings → Providers → Moonshot / Kimi API. + CodexBar stores the key after you paste it in Settings → Providers → Moonshot / Kimi Open Platform. 2. **Region** - - International: `https://api.moonshot.ai/v1/users/me/balance` - - China mainland: `https://api.moonshot.cn/v1/users/me/balance` + - International: `https://api.moonshot.ai/v1/users/me/balance` (console: platform.moonshot.ai) + - China mainland: `https://api.moonshot.cn/v1/users/me/balance` (console: platform.kimi.com) - Configure with Settings → Providers → Moonshot → API region or `MOONSHOT_REGION`. 3. **Balance endpoint** - Request headers: `Authorization: Bearer `, `Accept: application/json` @@ -37,7 +45,7 @@ third-party Kimi relays. - The menu card shows the available balance. - If `cash_balance` is negative, the card also surfaces the deficit. -- There is no session or weekly window — Moonshot / Kimi API does not expose per-window quota via API. +- There is no session or weekly window — the open platform does not expose per-window quota via this API. - Settings config takes precedence over environment variables when both are present. ## Key files diff --git a/docs/zai.md b/docs/zai.md index f02bc6615a..ac63f54a30 100644 --- a/docs/zai.md +++ b/docs/zai.md @@ -1,17 +1,44 @@ --- -summary: "z.ai provider data sources: API token in config/env and quota API response parsing." +summary: "z.ai / GLM Coding Plan: API key discovery, regions, and 5-hour quota mapping." read_when: - - Debugging z.ai token storage or quota parsing + - Debugging z.ai / BigModel token storage or quota parsing - Updating z.ai API endpoints + - Helping users with China Coding Plan (open.bigmodel.cn) --- -# z.ai provider +# z.ai / GLM provider -z.ai is API-token based. No browser cookies. +z.ai (international) and BigModel (China mainland GLM) share one CodexBar provider with an +**API region** switch: + +- Global: `api.z.ai` +- China mainland: `open.bigmodel.cn` (`region: bigmodel-cn`) + +Use a key from the same region you select. Keys are not interchangeable across regions. + +z.ai / BigModel is API-token based. No browser cookies. + +## Coding Plan vs MCP + +Quota API (`GET …/api/monitor/usage/quota/limit`) may return two limit rows: + +| API type | Typical window | Meaning | +| --- | --- | --- | +| `TOKENS_LIMIT` (`unit=hours`, `number=5`) | **5-hour** | **Coding Plan** main token budget | +| `TIME_LIMIT` with MCP `usageDetails` | tool/MCP | Search / web-reader style tool quota | + +CodexBar shows the **5-hour token window as the primary bar**. MCP is kept as an extra lane +(not mislabeled as monthly coding quota). Plan tier may appear as `level` (e.g. `max`). ## Token sources (fallback order) -1) Config token (`~/.config/codexbar/config.json` or legacy `~/.codexbar/config.json` → `providers[].apiKey`). -2) Environment variable `Z_AI_API_KEY`. + +1. Config token (`providers[].apiKey` in the resolved config file). +2. Environment (first match): `Z_AI_API_KEY`, `BIGMODEL_API_KEY`, `ZHIPU_API_KEY`, + `ZHIPUAI_API_KEY`, `ZAI_API_KEY`, `GLM_API_KEY`. +3. Local one-line key files under `$HOME` (first readable match): + - `~/.coding-relay/glm-api-key` + - `~/.config/bigmodel/api_key` + - `~/.config/zhipu/api_key` ### Config location - New installs: `~/.config/codexbar/config.json` @@ -22,7 +49,7 @@ z.ai is API-token based. No browser cookies. Set **API region** to **Global (api.z.ai)** or **BigModel CN (open.bigmodel.cn)**. -- UI: Settings → Providers → z.ai. For team usage, add a token account, turn on **Team mode**, then enter the API key, +- UI: Settings → Providers → z.ai / GLM. For team usage, add a token account, turn on **Team mode**, then enter the API key, Organization ID, and Project ID. - CLI personal: @@ -30,6 +57,8 @@ Set **API region** to **Global (api.z.ai)** or **BigModel CN (open.bigmodel.cn)* printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin ``` +- Or drop a one-line key at `~/.coding-relay/glm-api-key` (common for local coding relays) and enable the provider. + - CLI team: ```bash