Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!

### Changed
- Menu: move each usage window's used percentage and reset time into its title row, with all pace detail on one line (#2182). Thanks @jack24254029!

Expand Down
28 changes: 24 additions & 4 deletions Sources/CodexBar/Config/CodexBarConfigMigrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -164,14 +165,27 @@ 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,
state: inout MigrationState)
{
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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down
12 changes: 3 additions & 9 deletions Sources/CodexBar/MenuBarMetricWindowResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,27 +70,21 @@ 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]
}
return [.primary, .secondary]
}

private static func primaryOrder(for provider: UsageProvider) -> [Lane] {
if provider == .zai {
return [.primary, .tertiary, .secondary]
}
if provider == .perplexity || provider == .antigravity {
return [.primary, .secondary, .tertiary]
}
return [.primary, .secondary]
}

private static func secondaryOrder(for provider: UsageProvider) -> [Lane] {
if provider == .zai || provider == .antigravity {
if provider == .antigravity {
return [.secondary, .primary, .tertiary]
}
if provider == .perplexity {
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 10 additions & 4 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 6 additions & 8 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion Sources/CodexBar/Providers/Moonshot/MoonshotSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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),
Expand Down
5 changes: 0 additions & 5 deletions Sources/CodexBar/SessionQuotaNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Sources/CodexBar/SettingsStore+MenuPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore+PlanUtilization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
1 change: 0 additions & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,6 @@ final class UsageStore {
enum SessionQuotaWindowSource: String {
case primary
case copilotSecondaryFallback
case zaiTertiary
case antigravityQuotaSummary
case antigravityLegacy
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBarCLI/CLIConfigCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading