From e7f2de1586dc56c6f9dd6d30ffe3fe2d1885c60a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 12:42:41 -0700 Subject: [PATCH] feat: add China Kimi and GLM quota routing Co-authored-by: haoli --- CHANGELOG.md | 3 + .../Config/CodexBarConfigMigrator.swift | 28 ++++++- .../MenuBarMetricWindowResolver.swift | 12 +-- .../CodexBar/MenuCardView+ModelHelpers.swift | 14 +++- Sources/CodexBar/MenuCardView.swift | 14 ++-- Sources/CodexBar/MenuDescriptor.swift | 10 +++ .../Kimi/KimiProviderImplementation.swift | 10 ++- .../MoonshotProviderImplementation.swift | 20 ++--- .../Moonshot/MoonshotSettingsStore.swift | 15 +++- .../CodexBar/SessionQuotaNotifications.swift | 5 -- .../SettingsStore+MenuPreferences.swift | 4 +- .../CodexBar/UsageStore+PlanUtilization.swift | 2 +- Sources/CodexBar/UsageStore.swift | 1 - Sources/CodexBarCLI/CLIConfigCommand.swift | 3 + Sources/CodexBarCLI/CLIRenderer.swift | 38 ++++++++++ .../CodexBarCore/Config/CodexBarConfig.swift | 20 ++++- .../Config/ProviderConfigEnvironment.swift | 16 ++++ .../Kimi/KimiProviderDescriptor.swift | 6 +- .../Moonshot/MoonshotProviderDescriptor.swift | 54 +++++++++---- .../Providers/Moonshot/MoonshotRegion.swift | 9 +++ .../Moonshot/MoonshotSettingsReader.swift | 26 +++++++ .../Providers/Zai/ZaiProviderDescriptor.swift | 12 +-- .../Providers/Zai/ZaiUsageStats.swift | 37 +++++---- .../CodexBarWidgetProvider.swift | 4 +- .../CodexBarTests/CLIConfigCommandTests.swift | 16 ++++ Tests/CodexBarTests/CLISnapshotTests.swift | 16 ++-- .../CodexBarConfigMigratorTests.swift | 25 ++++++ ...dexPresentationCharacterizationTests.swift | 32 ++++---- .../MenuBarMetricWindowResolverTests.swift | 6 +- .../MoonshotSettingsReaderTests.swift | 76 ++++++++++++++++++- .../ProviderConfigEnvironmentTests.swift | 31 ++++++-- .../ProviderSettingsDescriptorTests.swift | 3 +- .../SettingsStoreAdditionalTests.swift | 2 +- .../UsageStoreHighestUsageTests.swift | 8 +- ...StorePlanUtilizationCelebrationTests.swift | 28 +++---- .../WidgetProviderChoiceTests.swift | 4 +- Tests/CodexBarTests/ZaiMenuCardTests.swift | 10 ++- Tests/CodexBarTests/ZaiProviderTests.swift | 70 ++++++++++------- docs/kimi.md | 6 +- docs/moonshot.md | 10 ++- docs/zai.md | 13 ++-- 41 files changed, 530 insertions(+), 189 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60899cca4d..326e186857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.47.1 — Unreleased +### Added +- Kimi/GLM: distinguish Kimi Code from the regional Open Platform, bind China and international keys to their issuing hosts, and show GLM Coding Plan's 5-hour window as primary with MCP separate (#2351). Thanks @Leehow! + ### Fixed ## 0.47.0 — 2026-08-03 diff --git a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift index 323d7a4668..fe4b86b872 100644 --- a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift +++ b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift @@ -40,6 +40,7 @@ struct CodexBarConfigMigrator { // applyLegacyCookieSources reads only UserDefaults — cheap, runs unconditionally so // newly-added cookie-source keys are picked up on every launch. self.applyLegacyCookieSources(userDefaults: userDefaults, config: &config, state: &state) + self.bindLegacyMoonshotAPIKeyRegion(config: &config, state: &state) let migrationCompleted = userDefaults.bool(forKey: Self.legacyMigrationCompletedKey) if !migrationCompleted { @@ -164,6 +165,17 @@ struct CodexBarConfigMigrator { } } + private static func bindLegacyMoonshotAPIKeyRegion( + config: inout CodexBarConfig, + state: inout MigrationState) + { + self.updateProvider(.moonshot, config: &config, state: &state) { entry in + guard entry.sanitizedAPIKey != nil, entry.sanitizedAPIKeyRegion == nil else { return false } + entry.apiKeyRegion = entry.sanitizedRegion ?? MoonshotRegion.international.rawValue + return true + } + } + private static func migrateTokenProviders( _ providers: [(UsageProvider, () throws -> String?)], config: inout CodexBarConfig, @@ -171,7 +183,9 @@ struct CodexBarConfigMigrator { { for (provider, loader) in providers { let token = try? loader() - if token != nil { state.sawLegacySecrets = true } + if token != nil { + state.sawLegacySecrets = true + } self.updateProvider(provider, config: &config, state: &state) { entry in self.setIfEmpty(&entry.apiKey, token) } @@ -185,7 +199,9 @@ struct CodexBarConfigMigrator { { for (provider, loader) in providers { let header = try? loader() - if header != nil { state.sawLegacySecrets = true } + if header != nil { + state.sawLegacySecrets = true + } self.updateProvider(provider, config: &config, state: &state) { entry in self.setIfEmpty(&entry.cookieHeader, header) } @@ -226,7 +242,9 @@ struct CodexBarConfigMigrator { if token?.isEmpty ?? true { token = userDefaults.string(forKey: "kimiManualCookieHeader") } - if token != nil { state.sawLegacySecrets = true } + if token != nil { + state.sawLegacySecrets = true + } self.updateProvider(.kimi, config: &config, state: &state) { entry in self.setIfEmpty(&entry.cookieHeader, token) } @@ -239,7 +257,9 @@ struct CodexBarConfigMigrator { state: inout MigrationState) { let header = try? stores.opencodeCookieStore.loadCookieHeader() - if header != nil { state.sawLegacySecrets = true } + if header != nil { + state.sawLegacySecrets = true + } let workspaceID = userDefaults.string(forKey: "opencodeWorkspaceID") self.updateProvider(.opencode, config: &config, state: &state) { entry in var changed = false diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index e99cbd1b36..738a87f578 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -70,9 +70,6 @@ enum MenuBarMetricWindowResolver { } private static func tertiaryOrder(for provider: UsageProvider) -> [Lane] { - if provider == .zai { - return [.tertiary, .primary, .secondary] - } if provider == .perplexity || provider == .cursor || provider == .antigravity { return [.tertiary, .secondary, .primary] } @@ -80,9 +77,6 @@ enum MenuBarMetricWindowResolver { } private static func primaryOrder(for provider: UsageProvider) -> [Lane] { - if provider == .zai { - return [.primary, .tertiary, .secondary] - } if provider == .perplexity || provider == .antigravity { return [.primary, .secondary, .tertiary] } @@ -90,7 +84,7 @@ enum MenuBarMetricWindowResolver { } private static func secondaryOrder(for provider: UsageProvider) -> [Lane] { - if provider == .zai || provider == .antigravity { + if provider == .antigravity { return [.secondary, .primary, .tertiary] } if provider == .perplexity { @@ -147,8 +141,8 @@ enum MenuBarMetricWindowResolver { if provider == .zai { return self.mostConstrainedWindow( primary: snapshot.primary, - secondary: snapshot.tertiary, - tertiary: nil) ?? snapshot.secondary + secondary: snapshot.secondary, + tertiary: nil) } if provider == .factory || provider == .kimi || provider == .litellm { if let exhausted = exhaustedWindow( diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 4528a4c9bd..1f69487110 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -821,9 +821,13 @@ extension UsageMenuCardView.Model { let resetText = input.provider == .sub2api && namedWindow.window.resetsAt == nil ? nil : resolvedResetText - let detailText = input.provider == .sub2api - ? namedWindow.window.resetDescription - : nil + let detailText: String? = if input.provider == .sub2api { + namedWindow.window.resetDescription + } else if input.provider == .zai, namedWindow.id == "zai-mcp" { + Self.zaiLimitDetailText(limit: input.snapshot?.zaiUsage?.timeLimit) + } else { + nil + } let statusText: String? = if usageKnown { nil } else if let resetText { @@ -921,7 +925,9 @@ extension UsageMenuCardView.Model { window: RateWindow, input: Input) -> PaceDetail? { - if provider == .claude, window.windowMinutes != 10080 { return nil } + if provider == .claude, window.windowMinutes != 10080 { + return nil + } guard provider == .codex || provider == .claude || provider == .antigravity else { return nil } switch window.windowMinutes { case 300: diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 61109da803..79af8d942f 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1181,9 +1181,10 @@ extension UsageMenuCardView.Model { var metrics: [Metric] = [] let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil - let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) - let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) - let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit) + let zaiPrimaryDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit ?? zaiUsage?.tokenLimit) + let zaiSecondaryDetail = zaiUsage?.sessionTokenLimit == nil + ? nil + : Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) let openRouterQuotaDetail = Self.openRouterQuotaDetail( provider: input.provider, snapshot: snapshot, @@ -1214,7 +1215,7 @@ extension UsageMenuCardView.Model { primary: primary, percentStyle: percentStyle, title: labels.primary, - zaiTokenDetail: zaiTokenDetail, + zaiTokenDetail: zaiPrimaryDetail, openRouterQuotaDetail: openRouterQuotaDetail)) } if input.provider != .codex, let weekly = snapshot.secondary { @@ -1223,7 +1224,7 @@ extension UsageMenuCardView.Model { weekly: weekly, percentStyle: percentStyle, title: labels.secondary, - zaiTimeDetail: zaiTimeDetail)) + zaiTimeDetail: zaiSecondaryDetail)) } if input.provider == .mimo, let mimoUsage = snapshot.mimoUsage { metrics.append(Metric( @@ -1247,9 +1248,6 @@ extension UsageMenuCardView.Model { { tertiaryDetailText = detail } - if input.provider == .zai, let detail = zaiSessionDetail { - tertiaryDetailText = detail - } // Perplexity purchased credits don't reset; show balance without "Resets" prefix. let opusResetText: String? = input.provider == .perplexity || input.provider == .sub2api ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 5c8a5652b0..ba247ba95f 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -323,6 +323,16 @@ struct MenuDescriptor { showUsed: settings.usageBarsShowUsed, resetOverride: opusResetOverride) } + if provider == .zai { + for extra in snap.extraRateWindows ?? [] where extra.id == "zai-mcp" { + Self.appendRateWindow( + entries: &entries, + title: extra.title, + window: extra.window, + resetStyle: resetStyle, + showUsed: settings.usageBarsShowUsed) + } + } Self.appendProviderUsageSummaries( entries: &entries, diff --git a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift index ca6be08aeb..75a52d2800 100644 --- a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift @@ -75,8 +75,9 @@ 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: "Kimi Code subscription usage from api.kimi.com. Auto tries your configured API key, " + + "then a signed-in Kimi Code CLI credential, then web cookies. China Open Platform balance " + + "is a separate provider.", binding: usageBinding, options: usageOptions, isVisible: nil, @@ -103,8 +104,9 @@ 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: "Kimi Code key from www.kimi.com/code. For China Open Platform balance, use " + + "Moonshot / Kimi Open Platform.", kind: .secure, placeholder: "Paste Kimi Code API key...", binding: context.stringBinding(\.kimiAPIKey), diff --git a/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift b/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift index c96088fd24..53cd93ed86 100644 --- a/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift @@ -26,12 +26,12 @@ struct MoonshotProviderImplementation: ProviderImplementation { @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { - if MoonshotSettingsReader.apiKey(environment: context.environment) != nil { + let region = context.settings.moonshotRegion + if MoonshotSettingsReader.apiKey(for: region, environment: context.environment) != nil { return true } context.settings.ensureMoonshotAPITokenLoaded() - return !context.settings.moonshotAPIToken.trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty + return context.settings.hasMoonshotAPIToken(for: region) } @MainActor @@ -49,7 +49,8 @@ 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 balance only. Keys are bound to the selected regional host and cannot be " + + "sent to the other region.", binding: binding, options: options, isVisible: nil, @@ -62,21 +63,20 @@ struct MoonshotProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "moonshot-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json.", + title: "Open Platform API key", + subtitle: "Use a key issued for the selected region. Changing regions leaves the other key " + + "unavailable until you switch back or replace it.", kind: .secure, placeholder: "sk-...", binding: context.stringBinding(\.moonshotAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "moonshot-open-dashboard", - title: "Open Moonshot Console", + title: "Open regional 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/Moonshot/MoonshotSettingsStore.swift b/Sources/CodexBar/Providers/Moonshot/MoonshotSettingsStore.swift index beb808af3a..333bd76285 100644 --- a/Sources/CodexBar/Providers/Moonshot/MoonshotSettingsStore.swift +++ b/Sources/CodexBar/Providers/Moonshot/MoonshotSettingsStore.swift @@ -3,10 +3,16 @@ import Foundation extension SettingsStore { var moonshotAPIToken: String { - get { self.configSnapshot.providerConfig(for: .moonshot)?.sanitizedAPIKey ?? "" } + get { + guard let config = self.configSnapshot.providerConfig(for: .moonshot), + config.sanitizedAPIKeyRegion == self.moonshotRegion.rawValue + else { return "" } + return config.sanitizedAPIKey ?? "" + } set { self.updateProviderConfig(provider: .moonshot) { entry in entry.apiKey = self.normalizedConfigValue(newValue) + entry.apiKeyRegion = entry.apiKey == nil ? nil : self.moonshotRegion.rawValue } self.logSecretUpdate(provider: .moonshot, field: "apiKey", value: newValue) } @@ -26,6 +32,13 @@ extension SettingsStore { func ensureMoonshotAPITokenLoaded() {} + func hasMoonshotAPIToken(for region: MoonshotRegion) -> Bool { + guard let config = self.configSnapshot.providerConfig(for: .moonshot), + config.sanitizedAPIKeyRegion == region.rawValue + else { return false } + return config.sanitizedAPIKey != nil + } + var configuredMoonshotRegion: MoonshotRegion? { guard let raw = self.configSnapshot.providerConfig(for: .moonshot)?.region? .trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index d1b0f6dd0d..bd15d3aaf8 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -419,11 +419,6 @@ extension UsageStore { : .antigravityLegacy return (window, source) } - // z.ai's typed sessionTokenLimit is rendered in the tertiary lane when the response also - // contains its weekly token limit and MCP time limit. Prefer that semantic session lane. - if provider == .zai, let tertiary = snapshot.tertiary { - return (tertiary, .zaiTertiary) - } if let primary = snapshot.primary, Self.isSessionWindow(primary) { // Crof credits-only balances publish a duration-less primary with no secondary quota // window. Keep that PAYG shape out of session-quota transitions so a $0 balance cannot diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 7f28ffa86f..0fb31a2717 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -295,11 +295,11 @@ extension SettingsStore { } func menuBarMetricSupportsTertiary(for provider: UsageProvider) -> Bool { - provider == .cursor || provider == .perplexity || provider == .zai + provider == .cursor || provider == .perplexity } func menuBarMetricSupportsTertiary(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool { - if provider == .cursor || provider == .zai { + if provider == .cursor { return snapshot?.tertiary != nil } return self.menuBarMetricSupportsTertiary(for: provider) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 4842993976..00128c72ac 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -532,7 +532,7 @@ extension UsageStore { case .primary: guard let minutes = resolved.window.windowMinutes else { return false } return minutes > 0 && minutes <= 6 * 60 - case .copilotSecondaryFallback, .zaiTertiary, .antigravityQuotaSummary, .antigravityLegacy: + case .copilotSecondaryFallback, .antigravityQuotaSummary, .antigravityLegacy: return true } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index e67609b8e5..d43fb9a16c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -925,7 +925,6 @@ final class UsageStore { enum SessionQuotaWindowSource: String { case primary case copilotSecondaryFallback - case zaiTertiary case antigravityQuotaSummary case antigravityLegacy } diff --git a/Sources/CodexBarCLI/CLIConfigCommand.swift b/Sources/CodexBarCLI/CLIConfigCommand.swift index 3df7f5685f..e2f6ef2451 100644 --- a/Sources/CodexBarCLI/CLIConfigCommand.swift +++ b/Sources/CodexBarCLI/CLIConfigCommand.swift @@ -251,6 +251,9 @@ extension CodexBarCLI { return updated } providerConfig.apiKey = apiKey + if provider == .moonshot { + providerConfig.apiKeyRegion = providerConfig.sanitizedRegion ?? MoonshotRegion.international.rawValue + } if enableProvider { providerConfig.enabled = true } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 026f6b8cbb..4bd96f3986 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -41,6 +41,12 @@ enum CLIRenderer { context: context, now: now, lines: &lines) + self.appendZaiExtraRateWindows( + provider: provider, + snapshot: snapshot, + context: context, + now: now, + lines: &lines) self.appendMiMoBalanceLine(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendClawRouterUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendSub2APIUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) @@ -116,6 +122,12 @@ enum CLIRenderer { context: context, now: now, lines: &lines) + self.appendZaiExtraRateWindows( + provider: provider, + snapshot: snapshot, + context: context, + now: now, + lines: &lines) self.appendMiMoBalanceLine(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendClawRouterUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendSub2APIUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) @@ -450,6 +462,16 @@ enum CLIRenderer { resetStyle: resetStyle, now: now)) } + if provider == .zai { + for extra in snapshot.extraRateWindows ?? [] where extra.id == "zai-mcp" { + metrics.append(self.makeCardMetric( + provider: provider, + label: extra.title, + window: extra.window, + resetStyle: resetStyle, + now: now)) + } + } return metrics } @@ -830,6 +852,22 @@ enum CLIRenderer { } } + private static func appendZaiExtraRateWindows( + provider: UsageProvider, + snapshot: UsageSnapshot, + context: RenderContext, + now: Date, + lines: inout [String]) + { + guard provider == .zai else { return } + for extra in snapshot.extraRateWindows ?? [] where extra.id == "zai-mcp" { + lines.append(self.rateLine(title: extra.title, window: extra.window, useColor: context.useColor)) + if let reset = self.resetLine(for: extra.window, style: context.resetStyle, now: now) { + lines.append(self.subtleLine(reset, useColor: context.useColor)) + } + } + } + private static func appendDeepgramLines( snapshot: UsageSnapshot, useColor: Bool, diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index b72e42d401..f8932551e5 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -72,7 +72,9 @@ public struct CodexBarConfig: Codable, Sendable { UsageProvider.allCases.sorted { lhs, rhs in let lhsEnabled = enablement(lhs) let rhsEnabled = enablement(rhs) - if lhsEnabled != rhsEnabled { return lhsEnabled } + if lhsEnabled != rhsEnabled { + return lhsEnabled + } let lhsName = metadata[lhs]?.displayName ?? lhs.rawValue let rhsName = metadata[rhs]?.displayName ?? rhs.rawValue switch lhsName.localizedCaseInsensitiveCompare(rhsName) { @@ -97,6 +99,12 @@ public struct CodexBarConfig: Codable, Sendable { provider.deepseekProfileID = provider.sanitizedDeepSeekProfileID provider.deepseekProfileScope = provider.sanitizedDeepSeekProfileScope } + if provider.id == .moonshot, + provider.sanitizedAPIKey != nil, + provider.sanitizedAPIKeyRegion == nil + { + provider.apiKeyRegion = provider.sanitizedRegion ?? MoonshotRegion.international.rawValue + } normalized.append(provider) } @@ -183,6 +191,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var awsAuthMode: String? public var deepseekProfileID: String? public var deepseekProfileScope: String? + /// Region that owns `apiKey`. Region-routed providers use this to keep credentials host-scoped. + public var apiKeyRegion: String? public init( id: UsageProvider, @@ -209,7 +219,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { awsProfile: String? = nil, awsAuthMode: String? = nil, deepseekProfileID: String? = nil, - deepseekProfileScope: String? = nil) + deepseekProfileScope: String? = nil, + apiKeyRegion: String? = nil) { self.id = id self.enabled = enabled @@ -236,6 +247,7 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.awsAuthMode = awsAuthMode self.deepseekProfileID = deepseekProfileID self.deepseekProfileScope = deepseekProfileScope + self.apiKeyRegion = apiKeyRegion } public var sanitizedAPIKey: String? { @@ -254,6 +266,10 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { Self.clean(self.region) } + public var sanitizedAPIKeyRegion: String? { + Self.clean(self.apiKeyRegion) + } + public var sanitizedWorkspaceID: String? { Self.clean(self.workspaceID) } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 0fbe433390..70d4698794 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -103,6 +103,8 @@ public enum ProviderConfigEnvironment { self.applyAzureOpenAIOverrides(base: base, config: config) case .kimi: self.applyKimiOverrides(base: base, config: config) + case .moonshot: + self.applyMoonshotOverrides(base: base, config: config) case .doubao: self.applyDoubaoOverrides(base: base, config: config) case .sakana: @@ -370,6 +372,20 @@ public enum ProviderConfigEnvironment { return env } + private static func applyMoonshotOverrides( + base: [String: String], + config: ProviderConfig?) -> [String: String] + { + guard let config, + let apiKey = config.sanitizedAPIKey, + let apiKeyRegion = config.sanitizedAPIKeyRegion + else { return base } + var env = base + env[MoonshotSettingsReader.configAPIKeyEnvironmentKey] = apiKey + env[MoonshotSettingsReader.configAPIKeyRegionEnvironmentKey] = apiKeyRegion + return env + } + private static func applyDoubaoOverrides( base: [String: String], config: ProviderConfig?) -> [String: String] diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index 731623ee07..052e0e9408 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -10,14 +10,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 usage", cliName: "kimi", defaultEnabled: false, isPrimaryProvider: false, @@ -36,7 +36,7 @@ public enum KimiProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Kimi cost summary is not supported." }), + noDataMessage: { "Kimi Code cost summary is not supported." }), pace: ProviderPaceCapability( resetWindowPace: .windowDuration(minutes: self.weeklyWindowMinutes)), fetchPlan: ProviderFetchPlan( diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift index 8ab1d194e9..5e10db9248 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -8,7 +8,7 @@ public enum MoonshotProviderDescriptor { id: .moonshot, metadata: ProviderMetadata( id: .moonshot, - displayName: "Moonshot / Kimi API", + displayName: "Moonshot / Kimi Open Platform", shortDisplayName: "Moonshot", sessionLabel: "Balance", weeklyLabel: "Balance", @@ -16,7 +16,7 @@ public enum MoonshotProviderDescriptor { supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Moonshot / Kimi API balance", + toggleTitle: "Show Moonshot / Kimi Open Platform balance", cliName: "moonshot", defaultEnabled: false, widgetSelectable: false, @@ -36,21 +36,47 @@ public enum MoonshotProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Moonshot / Kimi API cost summary is not available." }), - fetchPlan: .apiToken( - strategyID: "moonshot.api", - resolveToken: { ProviderTokenResolver.moonshotToken(environment: $0) }, - missingCredentialsError: { MoonshotUsageError.missingCredentials }, - loadUsage: { apiKey, context in - let region = - context.settings?.moonshot?.region ?? MoonshotSettingsReader.region(environment: context.env) - return try await MoonshotUsageFetcher.fetchUsage( - apiKey: apiKey, - region: region).toUsageSnapshot() - }), + noDataMessage: { "Moonshot / Kimi Open Platform cost summary is not available." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MoonshotAPIFetchStrategy()] })), cli: ProviderCLIConfig( name: "moonshot", aliases: [], versionDetector: nil)) } } + +struct MoonshotAPIFetchStrategy: ProviderFetchStrategy { + let id = "moonshot.api" + let kind: ProviderFetchKind = .apiToken + private let transport: any ProviderHTTPTransport + + init(transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { + self.transport = transport + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + MoonshotSettingsReader.apiKey(for: self.region(context), environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let region = self.region(context) + guard let apiKey = MoonshotSettingsReader.apiKey(for: region, environment: context.env) else { + throw MoonshotUsageError.missingCredentials + } + let usage = try await MoonshotUsageFetcher.fetchUsage( + apiKey: apiKey, + region: region, + session: self.transport) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private func region(_ context: ProviderFetchContext) -> MoonshotRegion { + context.settings?.moonshot?.region ?? MoonshotSettingsReader.region(environment: context.env) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift index 420e3ff1e7..ffce784ba2 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift @@ -27,4 +27,13 @@ public enum MoonshotRegion: String, CaseIterable, Sendable { public var balanceURL: URL { URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.balancePath) } + + public var consoleURL: URL { + switch self { + case .international: + URL(string: "https://platform.moonshot.ai/console/account")! + case .china: + URL(string: "https://platform.kimi.com/console/account")! + } + } } diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift index b01db6eca5..a868fa4049 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift @@ -6,10 +6,24 @@ public struct MoonshotSettingsReader: Sendable { "MOONSHOT_KEY", ] public static let regionEnvironmentKey = "MOONSHOT_REGION" + public static let configAPIKeyEnvironmentKey = "CODEXBAR_MOONSHOT_API_KEY" + public static let configAPIKeyRegionEnvironmentKey = "CODEXBAR_MOONSHOT_API_KEY_REGION" public static func apiKey( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.apiKey(for: self.region(environment: environment), environment: environment) + } + + public static func apiKey( + for region: MoonshotRegion, + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + if let apiKey = self.regionBoundConfigAPIKey(for: region, environment: environment) { + return apiKey + } + + guard self.region(environment: environment) == region else { return nil } for key in self.apiKeyEnvironmentKeys { guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty @@ -25,6 +39,18 @@ public struct MoonshotSettingsReader: Sendable { return nil } + private static func regionBoundConfigAPIKey( + for region: MoonshotRegion, + environment: [String: String]) -> String? + { + guard let rawRegion = environment[self.configAPIKeyRegionEnvironmentKey], + MoonshotRegion(rawValue: cleaned(rawRegion).lowercased()) == region, + let rawKey = environment[self.configAPIKeyEnvironmentKey] + else { return nil } + let key = Self.cleaned(rawKey) + return key.isEmpty ? nil : key + } + public static func region( environment: [String: String] = ProcessInfo.processInfo.environment) -> MoonshotRegion { diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index 7829ecfaff..61aa7bc180 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -8,14 +8,14 @@ public enum ZaiProviderDescriptor { id: .zai, metadata: ProviderMetadata( id: .zai, - displayName: "z.ai", - sessionLabel: "Tokens", - weeklyLabel: "MCP", - opusLabel: "5-hour", - supportsOpus: true, + displayName: "z.ai / GLM", + sessionLabel: "5-hour", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, 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/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 722691d360..2fb8221bb3 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -202,15 +202,18 @@ 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 + let primaryLimit = self.sessionTokenLimit ?? self.tokenLimit ?? self.timeLimit + let secondaryLimit = self.sessionTokenLimit == nil ? nil : self.tokenLimit let primary = primaryLimit.map { Self.rateWindow(for: $0) } ?? RateWindow( usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = secondaryLimit.map { Self.rateWindow(for: $0) } - let tertiary = self.sessionTokenLimit.map { Self.rateWindow(for: $0) } + let hasCodingLimit = self.tokenLimit != nil || self.sessionTokenLimit != nil + let extraRateWindows = hasCodingLimit ? self.timeLimit.map { + [NamedRateWindow(id: "zai-mcp", title: "MCP", window: Self.rateWindow(for: $0))] + } : nil let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -222,7 +225,8 @@ extension ZaiUsageSnapshot { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: tertiary, + tertiary: nil, + extraRateWindows: extraRateWindows, providerCost: nil, zaiUsage: self, updatedAt: self.updatedAt, @@ -230,32 +234,23 @@ extension ZaiUsageSnapshot { } private static func rateWindow(for limit: ZaiLimitEntry) -> RateWindow { - let windowMinutes: Int? = if limit.isMCPMonthlyMarker { - ProviderPaceCapability.monthlyWindowSentinelMinutes - } else if limit.type == .timeLimit, let minutes = limit.windowMinutes { - minutes - } else if limit.type == .timeLimit { - ProviderPaceCapability.monthlyWindowSentinelMinutes - } else { - limit.windowMinutes - } - return RateWindow( + RateWindow( usedPercent: limit.usedPercent, - windowMinutes: windowMinutes, + windowMinutes: limit.type == .tokensLimit ? limit.windowMinutes : nil, resetsAt: limit.nextResetTime, resetDescription: self.resetDescription(for: limit)) } private static func resetDescription(for limit: ZaiLimitEntry) -> String? { - if limit.isMCPMonthlyMarker { - return "Monthly" + if limit.type == .timeLimit { + return "MCP" + } + if limit.type == .tokensLimit, limit.windowMinutes == 5 * 60 { + return "5-hour" } if let label = limit.windowLabel { return label } - if limit.type == .timeLimit { - return "Monthly" - } return nil } } @@ -292,6 +287,7 @@ private struct ZaiQuotaLimitData: Decodable { 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 @@ -303,6 +299,7 @@ private struct ZaiQuotaLimitData: Decodable { case plan case planType = "plan_type" case packageName + case level } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 4e327adbef..3c51039313 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -35,7 +35,7 @@ enum ProviderChoice: String, AppEnum { .qwencloud: DisplayRepresentation(title: "Qwen Cloud"), .antigravity: DisplayRepresentation(title: "Antigravity"), .cursor: DisplayRepresentation(title: "Cursor"), - .zai: DisplayRepresentation(title: "z.ai"), + .zai: DisplayRepresentation(title: "z.ai / GLM"), .copilot: DisplayRepresentation(title: "Copilot"), .devin: DisplayRepresentation(title: "Devin"), .minimax: DisplayRepresentation(title: "MiniMax"), @@ -43,7 +43,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/Tests/CodexBarTests/CLIConfigCommandTests.swift b/Tests/CodexBarTests/CLIConfigCommandTests.swift index 04422e1437..77a383a2bb 100644 --- a/Tests/CodexBarTests/CLIConfigCommandTests.swift +++ b/Tests/CodexBarTests/CLIConfigCommandTests.swift @@ -5,6 +5,22 @@ import Testing @testable import CodexBarCLI struct CLIConfigCommandTests { + @Test + func `Moonshot API key is bound to configured region`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .moonshot, region: MoonshotRegion.china.rawValue)) + + let updated = CodexBarCLI.configSettingAPIKey( + config, + provider: .moonshot, + apiKey: "china-token", + enableProvider: true) + + let moonshot = updated.providerConfig(for: .moonshot) + #expect(moonshot?.apiKey == "china-token") + #expect(moonshot?.apiKeyRegion == MoonshotRegion.china.rawValue) + } + @Test func `config set api key parses provider stdin and no enable flags`() throws { let parser = CommandParser(signature: CodexBarCLI._configSetAPIKeySignatureForTesting()) diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index c7c7d5ead6..f00fac81f8 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -1356,11 +1356,17 @@ struct CLISnapshotTests { } @Test - func `renders 5-hour tertiary row for zai`() { + func `renders GLM coding windows with MCP separate`() { let snap = UsageSnapshot( - primary: .init(usedPercent: 9, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - secondary: .init(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: .init(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + primary: .init(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: "5-hour"), + secondary: .init(usedPercent: 9, windowMinutes: 10080, resetsAt: nil, resetDescription: "1 week window"), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "zai-mcp", + title: "MCP", + window: .init(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: "MCP")), + ], updatedAt: Date(timeIntervalSince1970: 0)) let output = CLIRenderer.renderText( @@ -1374,7 +1380,7 @@ struct CLISnapshotTests { resetStyle: .absolute)) #expect(output.contains("5-hour:")) - #expect(output.contains("Tokens:")) + #expect(output.contains("Weekly:")) #expect(output.contains("MCP:")) } diff --git a/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift index b935a34291..f1bbab35a4 100644 --- a/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift +++ b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift @@ -5,6 +5,31 @@ import Testing @Suite(.serialized) struct CodexBarConfigMigratorTests { + @Test + func `legacy Moonshot key is bound to its selected region`() throws { + let suite = "CodexBarConfigMigratorTests-moonshot-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let configStore = testConfigStore(suiteName: suite) + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig( + id: .moonshot, + apiKey: "legacy-china-token", + region: MoonshotRegion.china.rawValue)) + try configStore.save(config) + + let migrated = CodexBarConfigMigrator.loadOrMigrate( + configStore: configStore, + userDefaults: defaults, + stores: Self.legacyStores( + secrets: CountingLegacySecretStore(), + accountStore: CountingTokenAccountStore())) + + #expect(migrated.providerConfig(for: .moonshot)?.apiKeyRegion == MoonshotRegion.china.rawValue) + #expect(try configStore.load()?.providerConfig(for: .moonshot)?.apiKeyRegion == MoonshotRegion.china.rawValue) + } + @Test func `legacy secret migration completion flag skips repeated scans`() throws { let suite = "CodexBarConfigMigratorTests-skip-\(UUID().uuidString)" diff --git a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift index e548571cf5..c16fae4675 100644 --- a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift @@ -423,7 +423,7 @@ struct CodexPresentationCharacterizationTests { } @Test - func `zai menu descriptor includes Tokens MCP and 5-hour rows`() { + func `zai menu descriptor includes 5-hour weekly and MCP rows`() { let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-zai-three-quota") settings.statusChecksEnabled = false @@ -436,20 +436,26 @@ struct CodexPresentationCharacterizationTests { store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( - usedPercent: 9, - windowMinutes: 10080, - resetsAt: nil, - resetDescription: nil), - secondary: RateWindow( - usedPercent: 50, - windowMinutes: nil, - resetsAt: nil, - resetDescription: nil), - tertiary: RateWindow( usedPercent: 25, windowMinutes: 300, resetsAt: nil, - resetDescription: nil), + resetDescription: "5-hour"), + secondary: RateWindow( + usedPercent: 9, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "1 week window"), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "zai-mcp", + title: "MCP", + window: RateWindow( + usedPercent: 50, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "MCP")), + ], updatedAt: Date(), identity: ProviderIdentitySnapshot( providerID: .zai, @@ -467,9 +473,9 @@ struct CodexPresentationCharacterizationTests { includeContextualActions: false) let lines = self.textLines(from: descriptor) - #expect(lines.contains(where: { $0.hasPrefix("Tokens:") })) #expect(lines.contains(where: { $0.hasPrefix("MCP:") })) #expect(lines.contains(where: { $0.hasPrefix("5-hour:") })) + #expect(lines.contains(where: { $0.hasPrefix("Weekly:") })) } private func makeSettingsStore(suite: String) -> SettingsStore { diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index a755e36384..e37c434d33 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -26,9 +26,9 @@ struct MenuBarMetricWindowResolverTests { @Test func `automatic metric uses zai 5-hour token lane when it is most constrained`() { let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: RateWindow(usedPercent: 92, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + primary: RateWindow(usedPercent: 92, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, updatedAt: Date()) let window = MenuBarMetricWindowResolver.rateWindow( diff --git a/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift b/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift index 3188f5dd64..3f95db9c7c 100644 --- a/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift +++ b/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift @@ -1,5 +1,20 @@ -import CodexBarCore +import Foundation import Testing +@testable import CodexBarCore + +private struct MoonshotStubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw MoonshotUsageError.missingCredentials + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } +} struct MoonshotSettingsReaderTests { @Test @@ -39,6 +54,65 @@ struct MoonshotSettingsReaderTests { #expect(MoonshotSettingsReader.region(environment: env) == .international) } + + @Test + func `region bound config key is unavailable to the other host`() { + let env = [ + MoonshotSettingsReader.configAPIKeyEnvironmentKey: "china-token", + MoonshotSettingsReader.configAPIKeyRegionEnvironmentKey: MoonshotRegion.china.rawValue, + ] + + #expect(MoonshotSettingsReader.apiKey(for: .china, environment: env) == "china-token") + #expect(MoonshotSettingsReader.apiKey(for: .international, environment: env) == nil) + } + + @Test + func `environment key requires a matching explicit China region`() { + let unscoped = ["MOONSHOT_API_KEY": "china-token"] + let china = [ + "MOONSHOT_API_KEY": "china-token", + "MOONSHOT_REGION": "china", + ] + + #expect(MoonshotSettingsReader.apiKey(for: .china, environment: unscoped) == nil) + #expect(MoonshotSettingsReader.apiKey(for: .international, environment: unscoped) == "china-token") + #expect(MoonshotSettingsReader.apiKey(for: .china, environment: china) == "china-token") + #expect(MoonshotSettingsReader.apiKey(for: .international, environment: china) == nil) + } + + @Test + func `strategy rejects mismatched key before building a request`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected Moonshot request to \(request.url?.absoluteString ?? "")") + throw URLError(.userAuthenticationRequired) + } + let env = [ + MoonshotSettingsReader.configAPIKeyEnvironmentKey: "international-token", + MoonshotSettingsReader.configAPIKeyRegionEnvironmentKey: MoonshotRegion.international.rawValue, + ] + let context = ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: .make(moonshot: .init(region: .china)), + fetcher: UsageFetcher(environment: env), + claudeFetcher: MoonshotStubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + let strategy = MoonshotAPIFetchStrategy(transport: transport) + + #expect(await strategy.isAvailable(context) == false) + await #expect { + try await strategy.fetch(context) + } throws: { error in + guard case MoonshotUsageError.missingCredentials = error else { return false } + return true + } + #expect(await transport.requests().isEmpty) + } } struct MoonshotProviderTokenResolverTests { diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index ae13927d66..f45c0552ae 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -2,6 +2,25 @@ import CodexBarCore import Testing struct ProviderConfigEnvironmentTests { + @Test + func `projects Moonshot key with its bound region`() { + let config = ProviderConfig( + id: .moonshot, + apiKey: "china-token", + region: MoonshotRegion.china.rawValue, + apiKeyRegion: MoonshotRegion.china.rawValue) + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: ["MOONSHOT_API_KEY": "international-token"], + provider: .moonshot, + config: config) + + #expect(env["MOONSHOT_API_KEY"] == "international-token") + #expect(env[MoonshotSettingsReader.configAPIKeyEnvironmentKey] == "china-token") + #expect(env[MoonshotSettingsReader.configAPIKeyRegionEnvironmentKey] == "china") + #expect(MoonshotSettingsReader.apiKey(for: .china, environment: env) == "china-token") + #expect(MoonshotSettingsReader.apiKey(for: .international, environment: env) == "international-token") + } + @Test func `applies API key override for amp`() { let config = ProviderConfig(id: .amp, apiKey: "sgamp-config") @@ -283,17 +302,17 @@ struct ProviderConfigEnvironmentTests { @Test func `applies API key override for moonshot`() { - let config = ProviderConfig(id: .moonshot, apiKey: "moon-token") + let config = ProviderConfig( + id: .moonshot, + apiKey: "moon-token", + apiKeyRegion: MoonshotRegion.international.rawValue) let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], provider: .moonshot, config: config) - let key = MoonshotSettingsReader.apiKeyEnvironmentKeys.first - #expect(key != nil) - guard let key else { return } - - #expect(env[key] == "moon-token") + #expect(env[MoonshotSettingsReader.configAPIKeyEnvironmentKey] == "moon-token") + #expect(MoonshotSettingsReader.apiKey(for: .international, environment: env) == "moon-token") } @Test diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 89c1cd752d..7cba9fdd13 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -490,7 +490,8 @@ struct ProviderSettingsDescriptorTests { let usagePicker = try #require(pickers.first(where: { $0.id == "kimi-usage-source" })) #expect(usagePicker.options.map(\.id) == ["auto", "api", "web"]) #expect(usagePicker.subtitle == - "Auto tries your configured API key, then a signed-in Kimi Code CLI credential, then browser cookies.") + "Kimi Code subscription usage from api.kimi.com. Auto tries your configured API key, then a signed-in " + + "Kimi Code CLI credential, then web cookies. China Open Platform balance is a separate provider.") #expect(usagePicker.placement == .connection) #expect(usagePicker.trailingText?() == nil) fixture.store.lastSourceLabels[.kimi] = "Kimi Code CLI" diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index a50a595e39..e4b1d9faa8 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -67,7 +67,7 @@ struct SettingsStoreAdditionalTests { #expect(settings.menuBarMetricPreference(for: .zai) == .secondary) settings.setMenuBarMetricPreference(.tertiary, for: .zai) - #expect(settings.menuBarMetricPreference(for: .zai) == .tertiary) + #expect(settings.menuBarMetricPreference(for: .zai) == .automatic) #expect(settings.menuBarMetricPreference(for: .zai, snapshot: nil) == .automatic) #expect(settings.menuBarMetricSupportsTertiary(for: .zai, snapshot: nil) == false) diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 09005f135b..ff401a5567 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -541,7 +541,7 @@ extension UsageStoreHighestUsageTests { @Test func `automatic metric uses zai 5-hour token lane when ranking highest usage`() { let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-zai-automatic-tertiary"), + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-zai-automatic-primary"), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.refreshFrequency = .manual @@ -565,9 +565,9 @@ extension UsageStoreHighestUsageTests { secondary: nil, updatedAt: Date()) let zaiSnapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 15, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: RateWindow(usedPercent: 90, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + primary: RateWindow(usedPercent: 90, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, updatedAt: Date()) store._setSnapshotForTesting(codexSnapshot, provider: .codex) diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationCelebrationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationCelebrationTests.swift index 763c784b0f..9aa80e39c3 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationCelebrationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationCelebrationTests.swift @@ -1182,8 +1182,8 @@ extension UsageStorePlanUtilizationTests { defer { recorder.invalidate() } let before = UsageSnapshot( - primary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + primary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .zai, @@ -1191,8 +1191,8 @@ extension UsageStorePlanUtilizationTests { accountOrganization: accountLabel, loginMethod: "pro")) let after = UsageSnapshot( - primary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + primary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), updatedAt: Date(timeIntervalSince1970: 1_700_003_600), identity: ProviderIdentitySnapshot( providerID: .zai, @@ -1319,7 +1319,7 @@ extension UsageStorePlanUtilizationTests { @MainActor @Test - func `session quota celebration uses zai semantic tertiary session lane`() async { + func `session quota celebration uses zai primary 5-hour lane`() async { let store = Self.makeStore() let accountLabel = "zai-semantic-session-org" let recorder = SessionLimitResetEventRecorder(provider: .zai, accountLabel: accountLabel) @@ -1328,20 +1328,16 @@ extension UsageStorePlanUtilizationTests { func snapshot(sessionUsed: Double, updatedAt: Date) -> UsageSnapshot { UsageSnapshot( primary: RateWindow( - usedPercent: 30, - windowMinutes: 10080, - resetsAt: nil, - resetDescription: nil), - secondary: RateWindow( - usedPercent: 40, - windowMinutes: 43200, - resetsAt: nil, - resetDescription: "Monthly"), - tertiary: RateWindow( usedPercent: sessionUsed, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 30, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "1 week window"), + tertiary: nil, updatedAt: updatedAt, identity: ProviderIdentitySnapshot( providerID: .zai, @@ -1358,7 +1354,7 @@ extension UsageStorePlanUtilizationTests { #expect(recorder.events.count == 1) #expect(recorder.events.first?.usedPercent == 0) - #expect(store.sessionLimitResetDetectorStates.values.first?.sourceRawValue == "zaiTertiary") + #expect(store.sessionLimitResetDetectorStates.values.first?.sourceRawValue == "primary") } @MainActor diff --git a/Tests/CodexBarTests/WidgetProviderChoiceTests.swift b/Tests/CodexBarTests/WidgetProviderChoiceTests.swift index 7d43d7ddc4..877868775b 100644 --- a/Tests/CodexBarTests/WidgetProviderChoiceTests.swift +++ b/Tests/CodexBarTests/WidgetProviderChoiceTests.swift @@ -14,7 +14,7 @@ struct WidgetProviderChoiceTests { "qwencloud": "Qwen Cloud", "antigravity": "Antigravity", "cursor": "Cursor", - "zai": "z.ai", + "zai": "z.ai / GLM", "copilot": "Copilot", "devin": "Devin", "minimax": "MiniMax", @@ -22,7 +22,7 @@ struct WidgetProviderChoiceTests { "opencode": "OpenCode", "opencodego": "OpenCode Go", "mistral": "Mistral", - "kimi": "Kimi", + "kimi": "Kimi Code", ] @Test diff --git a/Tests/CodexBarTests/ZaiMenuCardTests.swift b/Tests/CodexBarTests/ZaiMenuCardTests.swift index 4433fdf9f5..ef9f6ba1f2 100644 --- a/Tests/CodexBarTests/ZaiMenuCardTests.swift +++ b/Tests/CodexBarTests/ZaiMenuCardTests.swift @@ -5,7 +5,7 @@ import Testing struct ZaiMenuCardTests { @Test - func `zai metrics titles are Tokens MCP and 5-hour when session token limit present`() throws { + func `zai metrics titles are 5-hour weekly and MCP when session token limit present`() throws { let now = Date() let zai = ZaiUsageSnapshot( tokenLimit: ZaiLimitEntry( @@ -63,8 +63,10 @@ struct ZaiMenuCardTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.map(\.title) == ["Tokens", "MCP", "5-hour"]) - let tertiary = try #require(model.metrics.first(where: { $0.title == "5-hour" })) - #expect(tertiary.detailText == "750 / 1K (250 remaining)") + #expect(model.metrics.map(\.title) == ["5-hour", "Weekly", "MCP"]) + let session = try #require(model.metrics.first(where: { $0.title == "5-hour" })) + #expect(session.detailText == "750 / 1K (250 remaining)") + let mcp = try #require(model.metrics.first(where: { $0.title == "MCP" })) + #expect(mcp.detailText == "50 / 100 (50 remaining)") } } diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index 8328a7709b..fb7b227a68 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -88,10 +88,13 @@ struct ZaiUsageSnapshotTests { #expect(usage.primary?.usedPercent == 20) #expect(usage.primary?.windowMinutes == 300) #expect(usage.primary?.resetsAt == reset) - #expect(usage.primary?.resetDescription == "5 hours window") - #expect(usage.secondary?.usedPercent == 20) - #expect(usage.secondary?.resetDescription == "30 days window") + #expect(usage.primary?.resetDescription == "5-hour") + #expect(usage.secondary == nil) #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows?.first?.id == "zai-mcp") + #expect(usage.extraRateWindows?.first?.window.usedPercent == 20) + #expect(usage.extraRateWindows?.first?.window.windowMinutes == nil) + #expect(usage.extraRateWindows?.first?.window.resetDescription == "MCP") #expect(usage.zaiUsage?.tokenLimit?.usage == 100) #expect(usage.zaiUsage?.sessionTokenLimit == nil) } @@ -120,7 +123,7 @@ struct ZaiUsageSnapshotTests { #expect(usage.primary?.usedPercent == 25) #expect(usage.primary?.windowMinutes == 300) #expect(usage.primary?.resetsAt == reset) - #expect(usage.primary?.resetDescription == "5 hours window") + #expect(usage.primary?.resetDescription == "5-hour") #expect(usage.zaiUsage?.tokenLimit?.usage == nil) } @@ -197,7 +200,7 @@ struct ZaiUsageSnapshotTests { } @Test - func `time limit with explicit duration preserves windowMinutes instead of monthly sentinel`() { + func `time limit does not fabricate a coding plan duration`() { let reset = Date(timeIntervalSince1970: 123) let timeLimit = ZaiLimitEntry( type: .timeLimit, @@ -217,12 +220,12 @@ struct ZaiUsageSnapshotTests { let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.windowMinutes == 300) - #expect(usage.primary?.resetDescription == "5 hours window") + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetDescription == "MCP") } @Test - func `time limit without explicit duration falls back to monthly sentinel`() { + func `time limit without explicit duration remains an MCP lane`() { let reset = Date(timeIntervalSince1970: 123) let timeLimit = ZaiLimitEntry( type: .timeLimit, @@ -242,7 +245,8 @@ struct ZaiUsageSnapshotTests { let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetDescription == "MCP") } } @@ -302,12 +306,15 @@ struct ZaiUsageParsingTests { #expect(snapshot.tokenLimit?.percentage == 34.0) let usage = snapshot.toUsageSnapshot() - #expect(usage.secondary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) - #expect(usage.secondary?.resetDescription == "Monthly") + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetDescription == "5-hour") + #expect(usage.secondary == nil) + #expect(usage.extraRateWindows?.first?.title == "MCP") + #expect(usage.extraRateWindows?.first?.window.windowMinutes == nil) } @Test - func `zai mcp time limit displays monthly instead of one minute window`() throws { + func `zai mcp time limit stays separate from the coding window`() throws { let json = """ { "code": 200, @@ -341,8 +348,10 @@ struct ZaiUsageParsingTests { let usage = snapshot.toUsageSnapshot() #expect(snapshot.timeLimit?.windowDescription == "1 minute") - #expect(usage.secondary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) - #expect(usage.secondary?.resetDescription == "Monthly") + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary == nil) + #expect(usage.extraRateWindows?.first?.window.windowMinutes == nil) + #expect(usage.extraRateWindows?.first?.window.resetDescription == "MCP") } @Test @@ -497,9 +506,13 @@ struct ZaiUsageParsingTests { let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.usedPercent == 7) - #expect(usage.secondary?.usedPercent == 14.7) - #expect(usage.tertiary?.usedPercent == 8) + #expect(snapshot.planName == "pro") + #expect(usage.primary?.usedPercent == 8) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 7) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows?.first?.window.usedPercent == 14.7) } } @@ -999,29 +1012,33 @@ struct ZaiThreeLimitTests { let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) - // Weekly token limit (unit:6=weeks, longer window) → tokenLimit (primary) + // Weekly token limit (unit:6=weeks, longer window) → tokenLimit (secondary) #expect(snapshot.tokenLimit?.unit == .weeks) #expect(snapshot.tokenLimit?.number == 1) #expect(snapshot.tokenLimit?.percentage == 9.0) #expect(snapshot.tokenLimit?.windowMinutes == 10080) - // 5-hour token limit (unit:3=hours, number:5 → 300 min) → sessionTokenLimit (tertiary) + // 5-hour token limit (unit:3=hours, number:5 → 300 min) → sessionTokenLimit (primary) #expect(snapshot.sessionTokenLimit?.unit == .hours) #expect(snapshot.sessionTokenLimit?.number == 5) #expect(snapshot.sessionTokenLimit?.percentage == 25.0) #expect(snapshot.sessionTokenLimit?.windowMinutes == 300) - // MCP time limit → timeLimit (secondary) + // MCP time limit → timeLimit (extra lane) #expect(snapshot.timeLimit?.usage == 1000) #expect(snapshot.timeLimit?.usageDetails.first?.modelCode == "search-prime") // UsageSnapshot slot mapping let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.usedPercent == 9.0) - #expect(usage.primary?.windowMinutes == 10080) - #expect(usage.secondary != nil) // MCP - #expect(usage.tertiary?.usedPercent == 25.0) - #expect(usage.tertiary?.windowMinutes == 300) + #expect(snapshot.planName == "pro") + #expect(usage.primary?.usedPercent == 25.0) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetDescription == "5-hour") + #expect(usage.secondary?.usedPercent == 9.0) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows?.first?.id == "zai-mcp") + #expect(abs((usage.extraRateWindows?.first?.window.usedPercent ?? 0) - 22.4) < 0.0001) } @Test @@ -1080,8 +1097,9 @@ struct ZaiThreeLimitTests { let usage = snapshot.toUsageSnapshot() #expect(usage.primary != nil) - #expect(usage.secondary != nil) + #expect(usage.secondary == nil) #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows?.first?.id == "zai-mcp") } } diff --git a/docs/kimi.md b/docs/kimi.md index fa1cc5a8c0..e4bb95b7cc 100644 --- a/docs/kimi.md +++ b/docs/kimi.md @@ -6,10 +6,14 @@ 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. +Kimi Code is distinct from the Moonshot/Kimi Open Platform. China-issued Open Platform keys and balance +belong under **Moonshot / Kimi Open Platform** with the China mainland region selected; they are not Kimi +Code subscription credentials. + ## Features - Displays weekly request quota (from membership tier) diff --git a/docs/moonshot.md b/docs/moonshot.md index 05500c8857..3e32c768be 100644 --- a/docs/moonshot.md +++ b/docs/moonshot.md @@ -1,14 +1,14 @@ --- -summary: "Moonshot / Kimi API provider data sources: API key + balance endpoint." +summary: "Moonshot / Kimi Open Platform data sources, regional key routing, and balance endpoint." read_when: - Adding or tweaking Moonshot balance parsing - Updating Moonshot / Kimi API key handling - Documenting Moonshot / Kimi API provider behavior --- -# Moonshot / Kimi API provider +# Moonshot / Kimi Open Platform provider -Moonshot / Kimi API is API-only. Balance is reported by `GET /v1/users/me/balance`, +Moonshot / Kimi Open Platform 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. ## Rationale @@ -24,11 +24,13 @@ third-party Kimi relays. ## 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 binds saved keys to the selected regional host. Switching regions does not send the saved key to + the other host; switch back or replace it with a key issued for the newly selected region. 2. **Region** - International: `https://api.moonshot.ai/v1/users/me/balance` - China mainland: `https://api.moonshot.cn/v1/users/me/balance` - Configure with Settings → Providers → Moonshot → API region or `MOONSHOT_REGION`. + - Environment keys default to International. Set `MOONSHOT_REGION=china` alongside a China-issued key. 3. **Balance endpoint** - Request headers: `Authorization: Bearer `, `Accept: application/json` - Response contains `available_balance`, `voucher_balance`, and `cash_balance`. diff --git a/docs/zai.md b/docs/zai.md index f02bc6615a..aa8ef9b8b7 100644 --- a/docs/zai.md +++ b/docs/zai.md @@ -1,13 +1,13 @@ --- -summary: "z.ai provider data sources: API token in config/env and quota API response parsing." +summary: "z.ai / GLM provider data sources, regions, and Coding Plan quota mapping." read_when: - Debugging z.ai token storage or quota parsing - Updating z.ai API endpoints --- -# z.ai provider +# z.ai / GLM provider -z.ai is API-token based. No browser cookies. +z.ai and China-mainland GLM Coding Plan are API-token based. No browser cookies. ## Token sources (fallback order) 1) Config token (`~/.config/codexbar/config.json` or legacy `~/.codexbar/config.json` → `providers[].apiKey`). @@ -126,10 +126,11 @@ Copy each value once, on one line. Multi-line or duplicated IDs can make the API ## Parsing + mapping - Response fields: - `data.limits[]` → each limit entry. - - `data.planName` (or `plan`, `plan_type`, `packageName`) → plan label. + - `data.planName` (or `plan`, `plan_type`, `packageName`, `level`) → plan label. - Limit types: - - `TOKENS_LIMIT` → primary (tokens window). - - `TIME_LIMIT` → secondary (MCP/time window) if tokens also present. + - Shortest `TOKENS_LIMIT` (normally 5 hours) → primary Coding Plan window. + - Longer `TOKENS_LIMIT` (normally weekly) → secondary window. + - `TIME_LIMIT` → a separate MCP lane, never a fabricated monthly Coding Plan window. - Window duration: - Unit + number → minutes/hours/days. - Reset: