diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml
new file mode 100644
index 000000000..2dd0da28a
--- /dev/null
+++ b/.github/workflows/build-app.yml
@@ -0,0 +1,58 @@
+name: Build CodexBar App
+
+on:
+ workflow_dispatch:
+ push:
+ branches: ["feat/qwen-doubao-providers"]
+
+jobs:
+ build-macos-app:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Select Xcode
+ run: |
+ set -euo pipefail
+ for candidate in /Applications/Xcode_26.1.1.app /Applications/Xcode_26.1.app /Applications/Xcode.app; do
+ if [[ -d "$candidate" ]]; then
+ sudo xcode-select -s "${candidate}/Contents/Developer"
+ echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV"
+ break
+ fi
+ done
+ xcodebuild -version
+ swift --version
+
+ - name: Resolve dependencies
+ run: swift package resolve
+
+ - name: Build release
+ run: swift build -c release 2>&1
+
+ - name: Run tests
+ run: swift test --no-parallel
+
+ - name: Fix rpath and package
+ run: |
+ set -euo pipefail
+ BIN_DIR="$(swift build -c release --show-bin-path)"
+ echo "Binary directory: $BIN_DIR"
+
+ # Add @executable_path/../Frameworks rpath so Sparkle.framework loads from .app bundle
+ install_name_tool -add_rpath @executable_path/../Frameworks "$BIN_DIR/CodexBar" || true
+ install_name_tool -add_rpath @executable_path/../Frameworks "$BIN_DIR/CodexBarCLI" || true
+
+ # Create a zip of the built products
+ cd "$BIN_DIR"
+ zip -r "$GITHUB_WORKSPACE/CodexBar-custom-build.zip" \
+ CodexBar CodexBarCLI CodexBarClaudeWatchdog CodexBarClaudeWebProbe \
+ CodexBar_CodexBar.bundle KeyboardShortcuts_KeyboardShortcuts.bundle \
+ Sparkle.framework
+
+ - name: Upload build artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: CodexBar-custom-build
+ path: CodexBar-custom-build.zip
+ retention-days: 30
diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift
index 05aa55fff..d90df6bd3 100644
--- a/Sources/CodexBar/MenuDescriptor.swift
+++ b/Sources/CodexBar/MenuDescriptor.swift
@@ -247,7 +247,14 @@ struct MenuDescriptor {
entries.append(.text("Activity: \(detail)", .secondary))
}
} else if let loginMethodText, !loginMethodText.isEmpty {
- entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText))", .secondary))
+ let formatted = AccountFormatter.plan(loginMethodText)
+ // Balance-style providers (openrouter, mimo) already emit "Balance: $X.XX";
+ // don't double-prefix with "Plan: " in that case.
+ if provider == .openrouter || provider == .mimo, formatted.hasPrefix("Balance:") {
+ entries.append(.text(formatted, .secondary))
+ } else {
+ entries.append(.text("Plan: \(formatted)", .secondary))
+ }
}
if metadata.usesAccountFallback {
diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift
index 58a55deb5..7b98b9649 100644
--- a/Sources/CodexBar/PreferencesProviderDetailView.swift
+++ b/Sources/CodexBar/PreferencesProviderDetailView.swift
@@ -27,7 +27,7 @@ struct ProviderDetailView: View {
else {
return nil
}
- guard provider == .openrouter else {
+ guard provider == .openrouter || provider == .mimo else {
return (label: "Plan", value: rawPlan)
}
diff --git a/Sources/CodexBar/Providers/AigoCode/AigoCodeProviderImplementation.swift b/Sources/CodexBar/Providers/AigoCode/AigoCodeProviderImplementation.swift
new file mode 100644
index 000000000..03ce723ba
--- /dev/null
+++ b/Sources/CodexBar/Providers/AigoCode/AigoCodeProviderImplementation.swift
@@ -0,0 +1,42 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct AigoCodeProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .aigocode
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.aigocodeAPIToken
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "aigocode-api-token",
+ title: "API key",
+ subtitle: "Optional when using web dashboard mode. "
+ + "Stored in ~/.codexbar/config.json.",
+ kind: .secure,
+ placeholder: "sk-...",
+ binding: context.stringBinding(\.aigocodeAPIToken),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "aigocode-open-dashboard",
+ title: "Open AigoCode Dashboard",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://www.aigocode.com/dashboard/console") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/AigoCode/AigoCodeSettingsStore.swift b/Sources/CodexBar/Providers/AigoCode/AigoCodeSettingsStore.swift
new file mode 100644
index 000000000..e43537736
--- /dev/null
+++ b/Sources/CodexBar/Providers/AigoCode/AigoCodeSettingsStore.swift
@@ -0,0 +1,14 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var aigocodeAPIToken: String {
+ get { self.configSnapshot.providerConfig(for: .aigocode)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .aigocode) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .aigocode, field: "apiKey", value: newValue)
+ }
+ }
+}
diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
new file mode 100644
index 000000000..fe974bed8
--- /dev/null
+++ b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
@@ -0,0 +1,42 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct DoubaoProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .doubao
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.doubaoAPIToken
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "doubao-api-token",
+ title: "API key",
+ subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine "
+ + "Ark console.",
+ kind: .secure,
+ placeholder: "ark-...",
+ binding: context.stringBinding(\.doubaoAPIToken),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "doubao-open-dashboard",
+ title: "Open Volcengine Ark Console",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://console.volcengine.com/ark/") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift
new file mode 100644
index 000000000..4d69a273f
--- /dev/null
+++ b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift
@@ -0,0 +1,14 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var doubaoAPIToken: String {
+ get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .doubao) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .doubao, field: "apiKey", value: newValue)
+ }
+ }
+}
diff --git a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift
new file mode 100644
index 000000000..bcb9b68cf
--- /dev/null
+++ b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift
@@ -0,0 +1,102 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct MiMoProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .mimo
+ let supportsLoginFlow: Bool = true
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { _ in "web" }
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.miMoCookieSource
+ _ = settings.miMoCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .mimo(context.settings.miMoSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.miMoCookieSource.rawValue },
+ set: { raw in
+ context.settings.miMoCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let cookieOptions = ProviderCookieSourceUI.options(
+ allowsOff: false,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+ let cookieSubtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.miMoCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Automatic imports Chrome browser cookies from Xiaomi MiMo.",
+ manual: "Paste a Cookie header from platform.xiaomimimo.com.",
+ off: "Xiaomi MiMo cookies are disabled.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "mimo-cookie-source",
+ title: "Cookie source",
+ subtitle: "Automatic imports Chrome browser cookies from Xiaomi MiMo.",
+ dynamicSubtitle: cookieSubtitle,
+ binding: cookieBinding,
+ options: cookieOptions,
+ isVisible: nil,
+ onChange: nil,
+ trailingText: {
+ guard let entry = CookieHeaderCache.load(provider: .mimo) else { return nil }
+ let when = entry.storedAt.relativeDescription()
+ return "Cached: \(entry.sourceLabel) • \(when)"
+ }),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "mimo-cookie",
+ title: "",
+ subtitle: "",
+ kind: .secure,
+ placeholder: "Cookie: ...",
+ binding: context.stringBinding(\.miMoCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "mimo-open-balance",
+ title: "Open MiMo Balance",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ guard let url = URL(string: "https://platform.xiaomimimo.com/#/console/balance") else {
+ return
+ }
+ NSWorkspace.shared.open(url)
+ }),
+ ],
+ isVisible: { context.settings.miMoCookieSource == .manual },
+ onActivate: { context.settings.ensureMiMoCookieLoaded() }),
+ ]
+ }
+
+ @MainActor
+ func runLoginFlow(context _: ProviderLoginContext) async -> Bool {
+ let loginURL = "https://platform.xiaomimimo.com/api/v1/genLoginUrl?currentPath=%2F%23%2Fconsole%2Fbalance"
+ guard let url = URL(string: loginURL) else {
+ return false
+ }
+ NSWorkspace.shared.open(url)
+ return false
+ }
+}
diff --git a/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift b/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift
new file mode 100644
index 000000000..3285a20b3
--- /dev/null
+++ b/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift
@@ -0,0 +1,35 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var miMoCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .mimo)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .mimo) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .mimo, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var miMoCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .mimo, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .mimo) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .mimo, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+
+ func ensureMiMoCookieLoaded() {}
+}
+
+extension SettingsStore {
+ func miMoSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.MiMoProviderSettings {
+ _ = tokenOverride
+ return ProviderSettingsSnapshot.MiMoProviderSettings(
+ cookieSource: self.miMoCookieSource,
+ manualCookieHeader: self.miMoCookieHeader)
+ }
+}
diff --git a/Sources/CodexBar/Providers/Qwen/QwenProviderImplementation.swift b/Sources/CodexBar/Providers/Qwen/QwenProviderImplementation.swift
new file mode 100644
index 000000000..5b981e26b
--- /dev/null
+++ b/Sources/CodexBar/Providers/Qwen/QwenProviderImplementation.swift
@@ -0,0 +1,42 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct QwenProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .qwen
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.qwenAPIToken
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "qwen-api-token",
+ title: "API key",
+ subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Alibaba Cloud "
+ + "Bailian console (DashScope).",
+ kind: .secure,
+ placeholder: "sk-...",
+ binding: context.stringBinding(\.qwenAPIToken),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "qwen-open-dashboard",
+ title: "Open Bailian Console",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://bailian.console.aliyun.com/") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Qwen/QwenSettingsStore.swift b/Sources/CodexBar/Providers/Qwen/QwenSettingsStore.swift
new file mode 100644
index 000000000..0aa570954
--- /dev/null
+++ b/Sources/CodexBar/Providers/Qwen/QwenSettingsStore.swift
@@ -0,0 +1,14 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var qwenAPIToken: String {
+ get { self.configSnapshot.providerConfig(for: .qwen)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .qwen) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .qwen, field: "apiKey", value: newValue)
+ }
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 7938b3d49..1879eea3a 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -35,6 +35,13 @@ enum ProviderImplementationRegistry {
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
+ case .qwen: QwenProviderImplementation()
+ case .doubao: DoubaoProviderImplementation()
+ case .zenmux: ZenmuxProviderImplementation()
+ case .aigocode: AigoCodeProviderImplementation()
+ case .trae: TraeProviderImplementation()
+ case .stepfun: StepFunProviderImplementation()
+ case .mimo: MiMoProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift b/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift
new file mode 100644
index 000000000..2b7811c9c
--- /dev/null
+++ b/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift
@@ -0,0 +1,41 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct StepFunProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .stepfun
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.stepfunAPIToken
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "stepfun-api-token",
+ title: "API key",
+ subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the StepFun platform.",
+ kind: .secure,
+ placeholder: "sf-...",
+ binding: context.stringBinding(\.stepfunAPIToken),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "stepfun-open-dashboard",
+ title: "Open StepFun Platform",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://platform.stepfun.com/plan-subscribe") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift
new file mode 100644
index 000000000..bd3c18e8e
--- /dev/null
+++ b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift
@@ -0,0 +1,14 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var stepfunAPIToken: String {
+ get { self.configSnapshot.providerConfig(for: .stepfun)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .stepfun) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .stepfun, field: "apiKey", value: newValue)
+ }
+ }
+}
diff --git a/Sources/CodexBar/Providers/Trae/TraeProviderImplementation.swift b/Sources/CodexBar/Providers/Trae/TraeProviderImplementation.swift
new file mode 100644
index 000000000..bd4b641ee
--- /dev/null
+++ b/Sources/CodexBar/Providers/Trae/TraeProviderImplementation.swift
@@ -0,0 +1,40 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct TraeProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .trae
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {}
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "trae-info",
+ title: "Status",
+ subtitle: "Trae is ByteDance's free AI IDE. CodexBar detects whether Trae is running "
+ + "on this machine.",
+ kind: .plain,
+ placeholder: "",
+ binding: context.stringBinding(\.traeInfo),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "trae-open-website",
+ title: "Open Trae Website",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://www.trae.ai") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Trae/TraeSettingsStore.swift b/Sources/CodexBar/Providers/Trae/TraeSettingsStore.swift
new file mode 100644
index 000000000..a9a83e60d
--- /dev/null
+++ b/Sources/CodexBar/Providers/Trae/TraeSettingsStore.swift
@@ -0,0 +1,10 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var traeInfo: String {
+ get { "" }
+ // swiftlint:disable:next unused_setter_value
+ set {}
+ }
+}
diff --git a/Sources/CodexBar/Providers/Zenmux/ZenmuxProviderImplementation.swift b/Sources/CodexBar/Providers/Zenmux/ZenmuxProviderImplementation.swift
new file mode 100644
index 000000000..06a9cae81
--- /dev/null
+++ b/Sources/CodexBar/Providers/Zenmux/ZenmuxProviderImplementation.swift
@@ -0,0 +1,42 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct ZenmuxProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .zenmux
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.zenmuxAPIToken
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "zenmux-api-token",
+ title: "API key",
+ subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Zenmux "
+ + "dashboard.",
+ kind: .secure,
+ placeholder: "sk-ss-v1-... or sk-ai-v1-...",
+ binding: context.stringBinding(\.zenmuxAPIToken),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "zenmux-open-dashboard",
+ title: "Open Zenmux Dashboard",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://zenmux.ai/") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Zenmux/ZenmuxSettingsStore.swift b/Sources/CodexBar/Providers/Zenmux/ZenmuxSettingsStore.swift
new file mode 100644
index 000000000..d5c231716
--- /dev/null
+++ b/Sources/CodexBar/Providers/Zenmux/ZenmuxSettingsStore.swift
@@ -0,0 +1,14 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var zenmuxAPIToken: String {
+ get { self.configSnapshot.providerConfig(for: .zenmux)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .zenmux) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .zenmux, field: "apiKey", value: newValue)
+ }
+ }
+}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-aigocode.svg b/Sources/CodexBar/Resources/ProviderIcon-aigocode.svg
new file mode 100644
index 000000000..95a151482
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-aigocode.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
new file mode 100644
index 000000000..9c20430a1
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/Resources/ProviderIcon-mimo.svg b/Sources/CodexBar/Resources/ProviderIcon-mimo.svg
new file mode 100644
index 000000000..50b1b8e3e
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-mimo.svg
@@ -0,0 +1,4 @@
+
diff --git a/Sources/CodexBar/Resources/ProviderIcon-qwen.svg b/Sources/CodexBar/Resources/ProviderIcon-qwen.svg
new file mode 100644
index 000000000..46e5ab262
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-qwen.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/Resources/ProviderIcon-trae.svg b/Sources/CodexBar/Resources/ProviderIcon-trae.svg
new file mode 100644
index 000000000..2c45783a9
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-trae.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/Resources/ProviderIcon-zenmux.svg b/Sources/CodexBar/Resources/ProviderIcon-zenmux.svg
new file mode 100644
index 000000000..44cffd96d
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-zenmux.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift
index 09f3e3caa..0a55a546d 100644
--- a/Sources/CodexBar/SettingsStore.swift
+++ b/Sources/CodexBar/SettingsStore.swift
@@ -157,8 +157,11 @@ final class SettingsStore {
extension SettingsStore {
private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState {
- let refreshRaw = userDefaults.string(forKey: "refreshFrequency") ?? RefreshFrequency.fiveMinutes.rawValue
- let refreshFrequency = RefreshFrequency(rawValue: refreshRaw) ?? .fiveMinutes
+ let refreshRaw = userDefaults.string(forKey: "refreshFrequency")
+ let refreshFrequency = refreshRaw.flatMap(RefreshFrequency.init(rawValue:)) ?? .fiveMinutes
+ if refreshRaw == nil {
+ userDefaults.set(refreshFrequency.rawValue, forKey: "refreshFrequency")
+ }
let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false
let debugMenuEnabled = userDefaults.object(forKey: "debugMenuEnabled") as? Bool ?? false
let debugDisableKeychainAccess: Bool = {
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index e3f0bcae4..9df485713 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1,3 +1,4 @@
+// swiftlint:disable file_length
import AppKit
import CodexBarCore
import Foundation
@@ -1140,6 +1141,7 @@ extension UsageStore {
await AugmentStatusProbe.latestDumps()
}
+ // swiftlint:disable:next cyclomatic_complexity
func debugLog(for provider: UsageProvider) async -> String {
if let cached = self.probeLogs[provider], !cached.isEmpty {
return cached
@@ -1240,6 +1242,32 @@ extension UsageStore {
let hasAny = resolution != nil
let source = resolution?.source.rawValue ?? "none"
text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
+ case .qwen:
+ let resolution = ProviderTokenResolver.qwenResolution()
+ let hasAny = resolution != nil
+ let source = resolution?.source.rawValue ?? "none"
+ text = "DASHSCOPE_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
+ case .doubao:
+ let resolution = ProviderTokenResolver.doubaoResolution()
+ let hasAny = resolution != nil
+ let source = resolution?.source.rawValue ?? "none"
+ text = "ARK_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
+ case .zenmux:
+ let resolution = ProviderTokenResolver.zenmuxResolution()
+ let hasAny = resolution != nil
+ let source = resolution?.source.rawValue ?? "none"
+ text = "ZENMUX_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
+ case .aigocode:
+ let resolution = ProviderTokenResolver.aigocodeResolution()
+ let hasAny = resolution != nil
+ let source = resolution?.source.rawValue ?? "none"
+ text = "AIGOCODE_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
+ case .trae:
+ text = "Trae: local probe (no API key needed)"
+ case .stepfun:
+ text = "StepFun: local probe (no API key needed)"
+ case .mimo:
+ text = "MiMo: local probe (cookie-based)"
case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2,
.jetbrains:
text = unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index 7324fa837..8744cf364 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -157,7 +157,8 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
- case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
+ case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp,
+ .qwen, .doubao, .zenmux, .aigocode, .trae, .stepfun, .mimo:
return nil
}
}
diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
index 9fabc4b80..4a1fd1cd8 100644
--- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
+++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
@@ -1,6 +1,7 @@
import Foundation
public enum ProviderConfigEnvironment {
+ // swiftlint:disable:next cyclomatic_complexity
public static func applyAPIKeyOverride(
base: [String: String],
provider: UsageProvider,
@@ -29,6 +30,30 @@ public enum ProviderConfigEnvironment {
}
case .openrouter:
env[OpenRouterSettingsReader.envKey] = apiKey
+ case .qwen:
+ if let key = QwenSettingsReader.apiKeyEnvironmentKeys.first {
+ env[key] = apiKey
+ }
+ case .doubao:
+ if let key = DoubaoSettingsReader.apiKeyEnvironmentKeys.first {
+ env[key] = apiKey
+ }
+ case .zenmux:
+ if let key = ZenmuxSettingsReader.apiKeyEnvironmentKeys.first {
+ env[key] = apiKey
+ }
+ case .aigocode:
+ if let key = AigoCodeSettingsReader.apiKeyEnvironmentKeys.first {
+ env[key] = apiKey
+ }
+ case .trae:
+ if let key = TraeSettingsReader.apiKeyEnvironmentKeys.first {
+ env[key] = apiKey
+ }
+ case .stepfun:
+ if let key = StepFunSettingsReader.apiKeyEnvironmentKeys.first {
+ env[key] = apiKey
+ }
default:
break
}
diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift
index 2243d5218..a21eb577e 100644
--- a/Sources/CodexBarCore/CostUsageFetcher.swift
+++ b/Sources/CodexBarCore/CostUsageFetcher.swift
@@ -40,8 +40,10 @@ public struct CostUsageFetcher: Sendable {
} else if provider == .claude {
options.claudeLogProviderFilter = .excludeVertexAI
}
+ // Always set TTL to 0: the scanner-level TTL is redundant with per-file mtime/size
+ // caching in processClaudeFile, and a non-zero TTL causes stale data after the first scan.
+ options.refreshMinIntervalSeconds = 0
if forceRefresh {
- options.refreshMinIntervalSeconds = 0
options.forceRescan = true
}
var daily = CostUsageScanner.loadDailyReport(
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index 37a7726ef..3564314d7 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -11,6 +11,9 @@ public enum LogCategories {
public static let claudeUsage = "claude-usage"
public static let codexRPC = "codex-rpc"
public static let configMigration = "config-migration"
+ public static let aigocodeUsage = "aigocode-usage"
+ public static let aigocodeWeb = "aigocode-web"
+ public static let doubaoUsage = "doubao-usage"
public static let configStore = "config-store"
public static let cookieCache = "cookie-cache"
public static let cookieHeaderStore = "cookie-header-store"
@@ -44,6 +47,7 @@ public enum LogCategories {
public static let opencodeUsage = "opencode-usage"
public static let openRouterUsage = "openrouter-usage"
public static let providerDetection = "provider-detection"
+ public static let qwenUsage = "qwen-usage"
public static let providers = "providers"
public static let sessionQuota = "sessionQuota"
public static let sessionQuotaNotifications = "sessionQuotaNotifications"
@@ -61,4 +65,9 @@ public enum LogCategories {
public static let zaiSettings = "zai-settings"
public static let zaiTokenStore = "zai-token-store"
public static let zaiUsage = "zai-usage"
+ public static let traeCookie = "trae-cookie"
+ public static let traeUsage = "trae-usage"
+ public static let traeWeb = "trae-web"
+ public static let zenmuxUsage = "zenmux-usage"
+ public static let stepfunUsage = "stepfun-usage"
}
diff --git a/Sources/CodexBarCore/Providers/AigoCode/AigoCodeCookieImporter.swift b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeCookieImporter.swift
new file mode 100644
index 000000000..1bd70e5b7
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeCookieImporter.swift
@@ -0,0 +1,164 @@
+#if os(macOS)
+import Foundation
+import SweetCookieKit
+
+/// Imports the AigoCode Supabase session from Chrome's localStorage.
+///
+/// AigoCode uses Supabase Auth which stores JWT tokens in localStorage under the key
+/// `sb-myptlcacxbuuxldgouqt-auth-token`, **not** in cookies. We use SweetCookieKit's
+/// `ChromiumLocalStorageReader` to properly parse Chrome's LevelDB files.
+public enum AigoCodeLocalStorageImporter {
+ private static let log = CodexBarLog.logger(LogCategories.aigocodeWeb)
+
+ /// The Supabase localStorage key for AigoCode's project.
+ static let supabaseTokenKey = "sb-myptlcacxbuuxldgouqt-auth-token"
+
+ /// The origins where the token may be stored.
+ private static let origins = [
+ "https://www.aigocode.com",
+ "https://aigocode.com",
+ ]
+
+ /// Extracted Supabase session from browser localStorage.
+ public struct SessionInfo: Sendable {
+ /// The raw JSON string stored under the Supabase token key.
+ public let tokenJSON: String
+ /// Which browser/profile the token was found in.
+ public let sourceLabel: String
+ }
+
+ /// Attempts to extract the Supabase session from Chrome's localStorage.
+ public static func importSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) -> SessionInfo?
+ {
+ let log: (String) -> Void = { msg in
+ logger?("[aigocode-storage] \(msg)")
+ self.log.debug(msg)
+ }
+
+ let candidates = self.chromeLocalStorageCandidates(browserDetection: browserDetection)
+ log("Found \(candidates.count) Chrome profile candidate(s)")
+
+ for candidate in candidates {
+ if let session = self.readSupabaseSession(from: candidate.levelDBURL, label: candidate.label, logger: log) {
+ return session
+ }
+ }
+
+ log("No Supabase session found in any browser profile")
+ return nil
+ }
+
+ /// Quick check for session availability.
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection()) -> Bool
+ {
+ self.importSession(browserDetection: browserDetection) != nil
+ }
+
+ // MARK: - LevelDB Reading
+
+ private static func readSupabaseSession(
+ from levelDBURL: URL,
+ label: String,
+ logger: ((String) -> Void)?) -> SessionInfo?
+ {
+ // Use SweetCookieKit's proper LevelDB parser for origin-scoped entries
+ for origin in self.origins {
+ let entries = ChromiumLocalStorageReader.readEntries(
+ for: origin,
+ in: levelDBURL,
+ logger: logger)
+
+ for entry in entries where entry.key == self.supabaseTokenKey {
+ let value = entry.value.trimmingCharacters(in: .controlCharacters)
+ guard !value.isEmpty else { continue }
+ // Validate it's actually JSON with access_token
+ if let data = value.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ json["access_token"] != nil
+ {
+ logger?("Found valid Supabase session in \(label) (origin: \(origin))")
+ return SessionInfo(tokenJSON: value, sourceLabel: label)
+ }
+ }
+ }
+
+ // Fallback: scan text entries for the key (handles edge cases)
+ let textEntries = ChromiumLocalStorageReader.readTextEntries(
+ in: levelDBURL,
+ logger: logger)
+
+ for entry in textEntries where entry.key.contains(self.supabaseTokenKey) {
+ let value = entry.value.trimmingCharacters(in: .controlCharacters)
+ guard !value.isEmpty else { continue }
+ if let data = value.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ json["access_token"] != nil
+ {
+ logger?("Found valid Supabase session in \(label) (text scan)")
+ return SessionInfo(tokenJSON: value, sourceLabel: label)
+ }
+ }
+
+ return nil
+ }
+
+ // MARK: - Chrome Profile Discovery
+
+ private struct LocalStorageCandidate {
+ let label: String
+ let levelDBURL: URL
+ }
+
+ private static func chromeLocalStorageCandidates(
+ browserDetection: BrowserDetection) -> [LocalStorageCandidate]
+ {
+ let browsers: [Browser] = [
+ .chrome,
+ .chromeBeta,
+ .chromeCanary,
+ .arc,
+ .arcBeta,
+ .arcCanary,
+ .chromium,
+ ]
+
+ let installedBrowsers = browsers.browsersWithProfileData(using: browserDetection)
+ let roots = ChromiumProfileLocator
+ .roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories())
+ .map { (url: $0.url, labelPrefix: $0.labelPrefix) }
+
+ var candidates: [LocalStorageCandidate] = []
+ for root in roots {
+ candidates.append(contentsOf: self.profileCandidates(root: root.url, labelPrefix: root.labelPrefix))
+ }
+ return candidates
+ }
+
+ private static func profileCandidates(root: URL, labelPrefix: String) -> [LocalStorageCandidate] {
+ guard let entries = try? FileManager.default.contentsOfDirectory(
+ at: root,
+ includingPropertiesForKeys: [.isDirectoryKey],
+ options: [.skipsHiddenFiles])
+ else { return [] }
+
+ let profileDirs = entries.filter { url in
+ guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else {
+ return false
+ }
+ let name = url.lastPathComponent
+ return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-")
+ }
+ .sorted { $0.lastPathComponent < $1.lastPathComponent }
+
+ return profileDirs.compactMap { dir in
+ let levelDBURL = dir.appendingPathComponent("Local Storage").appendingPathComponent("leveldb")
+ guard FileManager.default.fileExists(atPath: levelDBURL.path) else { return nil }
+ let label = "\(labelPrefix) \(dir.lastPathComponent)"
+ return LocalStorageCandidate(label: label, levelDBURL: levelDBURL)
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/AigoCode/AigoCodeDashboardFetcher.swift b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeDashboardFetcher.swift
new file mode 100644
index 000000000..b13381267
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeDashboardFetcher.swift
@@ -0,0 +1,316 @@
+#if os(macOS)
+import Foundation
+import WebKit
+
+/// Scrapes the AigoCode dashboard to extract usage data.
+///
+/// AigoCode uses Supabase + Next.js with server-side rendering. The usage data is only
+/// available by rendering the full dashboard page, so we use an offscreen WKWebView
+/// to load the page and extract values from the DOM via JavaScript.
+@MainActor
+public struct AigoCodeDashboardFetcher {
+ public enum FetchError: LocalizedError {
+ case loginRequired
+ case noUsageData(body: String)
+ case timeout
+
+ public var errorDescription: String? {
+ switch self {
+ case .loginRequired:
+ "AigoCode web access requires login. Open Settings → AigoCode → Login in Browser."
+ case let .noUsageData(body):
+ "AigoCode dashboard data not found. Body sample: \(body.prefix(200))"
+ case .timeout:
+ "AigoCode dashboard loading timed out."
+ }
+ }
+ }
+
+ private static let log = CodexBarLog.logger(LogCategories.aigocodeWeb)
+ private static let dashboardURL = URL(string: "https://www.aigocode.com/dashboard/console")!
+
+ public init() {}
+
+ // MARK: - Public
+
+ public func fetchDashboard(
+ websiteDataStore: WKWebsiteDataStore = .default(),
+ supabaseTokenJSON: String? = nil,
+ timeout: TimeInterval = 45) async throws -> AigoCodeDashboardSnapshot
+ {
+ let deadline = Date().addingTimeInterval(max(1, timeout))
+
+ let config = WKWebViewConfiguration()
+ config.websiteDataStore = websiteDataStore
+ let webView = WKWebView(frame: CGRect(x: -9999, y: -9999, width: 1200, height: 900), configuration: config)
+ webView.customUserAgent =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
+
+ defer {
+ webView.stopLoading()
+ webView.loadHTMLString("", baseURL: nil)
+ }
+
+ // If we have a Supabase token from Chrome, inject it into localStorage first.
+ // We load a blank page on the AigoCode origin, set the token, then navigate.
+ if let supabaseTokenJSON {
+ Self.log.debug("Injecting Supabase session into localStorage")
+ _ = webView.load(URLRequest(url: URL(string: "https://www.aigocode.com/favicon.ico")!))
+ try? await Task.sleep(for: .milliseconds(2000))
+
+ let escaped = supabaseTokenJSON
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "'", with: "\\'")
+ let injectJS =
+ "localStorage.setItem('\(AigoCodeLocalStorageImporter.supabaseTokenKey)', '\(escaped)'); 'ok';" // swiftlint:disable:this line_length
+ let result = try? await webView.evaluateJavaScript(injectJS)
+ Self.log.debug("localStorage injection result: \(String(describing: result))")
+ }
+
+ _ = webView.load(URLRequest(url: Self.dashboardURL))
+ Self.log.debug("Loading AigoCode dashboard…")
+
+ // Poll until we find usage data or hit the deadline
+ var lastBody = ""
+ while Date() < deadline {
+ try? await Task.sleep(for: .milliseconds(1500))
+
+ let scrape = try await self.scrape(webView: webView)
+
+ // Detect login page
+ if scrape.isLoginPage {
+ Self.log.debug("Login page detected")
+ throw FetchError.loginRequired
+ }
+
+ lastBody = scrape.bodyText
+
+ // Check if we have subscription usage data
+ if let snapshot = scrape.snapshot {
+ Self.log.debug(
+ "Dashboard parsed: subscription=\(snapshot.subscriptionUsedDollars)/\(snapshot.subscriptionTotalDollars) " + // swiftlint:disable:this line_length
+ "weekly=\(snapshot.weeklyUsedDollars)/\(snapshot.weeklyTotalDollars)")
+ return snapshot
+ }
+ }
+
+ throw FetchError.noUsageData(body: lastBody)
+ }
+
+ // MARK: - JavaScript Scraping
+
+ private struct ScrapeResult {
+ let isLoginPage: Bool
+ let bodyText: String
+ let snapshot: AigoCodeDashboardSnapshot?
+ }
+
+ private func scrape(webView: WKWebView) async throws -> ScrapeResult {
+ let js = """
+ (() => {
+ const href = window.location.href;
+ const body = document.body ? document.body.innerText : '';
+
+ // Detect login page
+ const isLogin = href.includes('/auth/login') ||
+ body.includes('欢迎回来') && body.includes('使用 Google 登录');
+
+ if (isLogin) {
+ return JSON.stringify({ isLogin: true, body: body.substring(0, 500) });
+ }
+
+ // Extract usage data from the console/stats page
+ // Pattern: "已用 $X / 共 $Y" or "已用 $X / $Y"
+ const usagePattern = /已用\\s*\\$([\\d,.]+)\\s*\\/\\s*共?\\s*\\$([\\d,.]+)/g;
+ const usages = [];
+ let match;
+ while ((match = usagePattern.exec(body)) !== null) {
+ usages.push({ used: match[1].replace(/,/g, ''), total: match[2].replace(/,/g, '') });
+ }
+
+ // Extract plan info
+ // Pattern: "Professional Plan" or similar, followed by expiration
+ const planMatch = body.match(/(\\w+\\s*Plan)[,,]?\\s*到期\\s*([\\d/]+)/);
+ const plan = planMatch ? planMatch[1] : null;
+ const expiry = planMatch ? planMatch[2] : null;
+
+ // Extract flexible balance
+ // Pattern: "<$0.01" or "$1.23"
+ const flexMatch = body.match(/灵活余额[\\s\\S]{0,50}?[<>]?\\$([\\d,.]+)/);
+ const flexBalance = flexMatch ? flexMatch[1].replace(/,/g, '') : null;
+
+ // Extract weekly reset info
+ // Pattern: "X天Y小时后重置" or "X小时后重置"
+ const resetMatch = body.match(/(\\d+天)?(\\d+小时)?后重置/);
+ const resetText = resetMatch ? resetMatch[0] : null;
+
+ // Extract usage percentage
+ const pctPattern = /已使用\\s*(\\d+)%/g;
+ const pcts = [];
+ let pctMatch;
+ while ((pctMatch = pctPattern.exec(body)) !== null) {
+ pcts.push(parseInt(pctMatch[1]));
+ }
+
+ return JSON.stringify({
+ isLogin: false,
+ body: body.substring(0, 1000),
+ usages: usages,
+ plan: plan,
+ expiry: expiry,
+ flexBalance: flexBalance,
+ resetText: resetText,
+ pcts: pcts,
+ href: href
+ });
+ })();
+ """
+
+ guard let resultStr = try await webView.evaluateJavaScript(js) as? String,
+ let data = resultStr.data(using: .utf8),
+ let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else {
+ return ScrapeResult(isLoginPage: false, bodyText: "", snapshot: nil)
+ }
+
+ let isLogin = (dict["isLogin"] as? Bool) ?? false
+ let bodyText = (dict["body"] as? String) ?? ""
+
+ if isLogin {
+ return ScrapeResult(isLoginPage: true, bodyText: bodyText, snapshot: nil)
+ }
+
+ guard let usages = dict["usages"] as? [[String: String]], !usages.isEmpty else {
+ return ScrapeResult(isLoginPage: false, bodyText: bodyText, snapshot: nil)
+ }
+
+ // First usage entry = subscription, second = weekly (if present)
+ let subUsed = Double(usages[0]["used"] ?? "0") ?? 0
+ let subTotal = Double(usages[0]["total"] ?? "0") ?? 0
+ let weekUsed = usages.count > 1 ? (Double(usages[1]["used"] ?? "0") ?? 0) : 0
+ let weekTotal = usages.count > 1 ? (Double(usages[1]["total"] ?? "0") ?? 0) : 0
+
+ let plan = dict["plan"] as? String
+ let expiry = dict["expiry"] as? String
+ let flexBalance = Double((dict["flexBalance"] as? String) ?? "0") ?? 0
+ let resetText = dict["resetText"] as? String
+
+ let snapshot = AigoCodeDashboardSnapshot(
+ subscriptionUsedDollars: subUsed,
+ subscriptionTotalDollars: subTotal,
+ weeklyUsedDollars: weekUsed,
+ weeklyTotalDollars: weekTotal,
+ planName: plan,
+ planExpiry: expiry,
+ flexibleBalanceDollars: flexBalance,
+ weeklyResetText: resetText,
+ updatedAt: Date())
+
+ return ScrapeResult(isLoginPage: false, bodyText: bodyText, snapshot: snapshot)
+ }
+}
+
+// MARK: - Dashboard Snapshot
+
+public struct AigoCodeDashboardSnapshot: Sendable {
+ public let subscriptionUsedDollars: Double
+ public let subscriptionTotalDollars: Double
+ public let weeklyUsedDollars: Double
+ public let weeklyTotalDollars: Double
+ public let planName: String?
+ public let planExpiry: String?
+ public let flexibleBalanceDollars: Double
+ public let weeklyResetText: String?
+ public let updatedAt: Date
+
+ public init(
+ subscriptionUsedDollars: Double,
+ subscriptionTotalDollars: Double,
+ weeklyUsedDollars: Double,
+ weeklyTotalDollars: Double,
+ planName: String?,
+ planExpiry: String?,
+ flexibleBalanceDollars: Double,
+ weeklyResetText: String?,
+ updatedAt: Date)
+ {
+ self.subscriptionUsedDollars = subscriptionUsedDollars
+ self.subscriptionTotalDollars = subscriptionTotalDollars
+ self.weeklyUsedDollars = weeklyUsedDollars
+ self.weeklyTotalDollars = weeklyTotalDollars
+ self.planName = planName
+ self.planExpiry = planExpiry
+ self.flexibleBalanceDollars = flexibleBalanceDollars
+ self.weeklyResetText = weeklyResetText
+ self.updatedAt = updatedAt
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let subPercent: Double
+ let subDescription: String
+ if self.subscriptionTotalDollars > 0 {
+ subPercent = min(100, max(0, self.subscriptionUsedDollars / self.subscriptionTotalDollars * 100))
+ subDescription = "$\(Self.fmt(self.subscriptionUsedDollars))/$\(Self.fmt(self.subscriptionTotalDollars))"
+ } else {
+ subPercent = 0
+ subDescription = "No subscription data"
+ }
+
+ let weekPercent: Double
+ let weekDescription: String
+ if self.weeklyTotalDollars > 0 {
+ weekPercent = min(100, max(0, self.weeklyUsedDollars / self.weeklyTotalDollars * 100))
+ var desc = "$\(Self.fmt(self.weeklyUsedDollars))/$\(Self.fmt(self.weeklyTotalDollars))"
+ if let reset = self.weeklyResetText {
+ desc += " (\(reset))"
+ }
+ weekDescription = desc
+ } else {
+ weekPercent = 0
+ weekDescription = "No weekly data"
+ }
+
+ let primary = RateWindow(
+ usedPercent: subPercent,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: subDescription)
+
+ let secondary = RateWindow(
+ usedPercent: weekPercent,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: weekDescription)
+
+ var planDescription: String?
+ if let plan = self.planName {
+ planDescription = plan
+ if let expiry = self.planExpiry {
+ planDescription! += ", expires \(expiry)"
+ }
+ }
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .aigocode,
+ accountEmail: planDescription,
+ accountOrganization: nil,
+ loginMethod: "Web")
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+
+ private static func fmt(_ value: Double) -> String {
+ if value == Double(Int(value)) {
+ return String(Int(value))
+ }
+ return String(format: "%.2f", value)
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/AigoCode/AigoCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeProviderDescriptor.swift
new file mode 100644
index 000000000..2db0b956b
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeProviderDescriptor.swift
@@ -0,0 +1,122 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum AigoCodeProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .aigocode,
+ metadata: ProviderMetadata(
+ id: .aigocode,
+ displayName: "AigoCode",
+ sessionLabel: "Subscription",
+ weeklyLabel: "Weekly",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show AigoCode usage",
+ cliName: "aigocode",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: nil,
+ dashboardURL: "https://www.aigocode.com/dashboard/console",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .aigocode,
+ iconResourceName: "ProviderIcon-aigocode",
+ color: ProviderColor(red: 34 / 255, green: 197 / 255, blue: 94 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "AigoCode cost summary is not available." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { context in
+ var strategies: [any ProviderFetchStrategy] = []
+ // Prefer web dashboard when available (works without API key)
+ #if os(macOS)
+ if context.sourceMode.usesWeb || context.sourceMode == .auto {
+ strategies.append(AigoCodeWebDashboardFetchStrategy())
+ }
+ #endif
+ strategies.append(AigoCodeAPIFetchStrategy())
+ return strategies
+ })),
+ cli: ProviderCLIConfig(
+ name: "aigocode",
+ aliases: ["aigo"],
+ versionDetector: nil))
+ }
+}
+
+struct AigoCodeAPIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "aigocode.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ Self.resolveToken(environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = Self.resolveToken(environment: context.env) else {
+ throw AigoCodeUsageError.missingCredentials
+ }
+ let usage = try await AigoCodeUsageFetcher.fetchUsage(apiKey: apiKey)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "api")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveToken(environment: [String: String]) -> String? {
+ ProviderTokenResolver.aigocodeToken(environment: environment)
+ }
+}
+
+#if os(macOS)
+/// Fetches AigoCode usage by rendering the dashboard in an offscreen WKWebView.
+/// This works without an API key — the user just needs to be logged in via the WebKit session.
+struct AigoCodeWebDashboardFetchStrategy: ProviderFetchStrategy {
+ let id: String = "aigocode.webDashboard"
+ let kind: ProviderFetchKind = .webDashboard
+ private static let log = CodexBarLog.logger(LogCategories.aigocodeWeb)
+
+ /// The web strategy is always considered available on macOS. If the user isn't logged in,
+ /// `fetch` will throw `loginRequired` and the pipeline falls back to the API strategy.
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ context.sourceMode.usesWeb || context.sourceMode == .auto
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ // Try to import the Supabase session from Chrome's localStorage.
+ // AigoCode uses Supabase Auth which stores JWT tokens in localStorage, not cookies.
+ let session = AigoCodeLocalStorageImporter.importSession()
+ if let session {
+ Self.log.debug("Found Supabase session in \(session.sourceLabel)")
+ } else {
+ Self.log.debug("No Supabase session found in browser localStorage")
+ }
+
+ let snapshot = try await MainActor.run {
+ AigoCodeDashboardFetcher()
+ }.fetchDashboard(
+ supabaseTokenJSON: session?.tokenJSON,
+ timeout: context.webTimeout)
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: "webDashboard")
+ }
+
+ func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool {
+ // Fall back to API strategy if web fails (login required, timeout, etc.)
+ if context.sourceMode == .auto { return true }
+ if context.sourceMode == .web { return false }
+ return true
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/AigoCode/AigoCodeSettingsReader.swift b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeSettingsReader.swift
new file mode 100644
index 000000000..c5534f622
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeSettingsReader.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+public struct AigoCodeSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKeys = [
+ "AIGOCODE_API_KEY",
+ ]
+
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ for key in self.apiKeyEnvironmentKeys {
+ guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !raw.isEmpty
+ else {
+ continue
+ }
+ let cleaned = Self.cleaned(raw)
+ if !cleaned.isEmpty {
+ return cleaned
+ }
+ }
+ return nil
+ }
+
+ private static func cleaned(_ raw: String) -> String {
+ var value = raw
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/AigoCode/AigoCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeUsageFetcher.swift
new file mode 100644
index 000000000..db0887926
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/AigoCode/AigoCodeUsageFetcher.swift
@@ -0,0 +1,288 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct AigoCodeUsageSnapshot: Sendable {
+ public let remainingRequests: Int
+ public let limitRequests: Int
+ public let resetTime: Date?
+ public let updatedAt: Date
+ public let apiKeyValid: Bool
+ public let totalTokens: Int?
+ public let statusMessage: String?
+ public let apiKey: String?
+
+ public init(
+ remainingRequests: Int,
+ limitRequests: Int,
+ resetTime: Date?,
+ updatedAt: Date,
+ apiKeyValid: Bool = false,
+ totalTokens: Int? = nil,
+ statusMessage: String? = nil,
+ apiKey: String? = nil)
+ {
+ self.remainingRequests = remainingRequests
+ self.limitRequests = limitRequests
+ self.resetTime = resetTime
+ self.updatedAt = updatedAt
+ self.apiKeyValid = apiKeyValid
+ self.totalTokens = totalTokens
+ self.statusMessage = statusMessage
+ self.apiKey = apiKey
+ }
+
+ private static func maskedKey(_ key: String?) -> String? {
+ guard let key, !key.isEmpty else { return nil }
+ if key.count <= 8 { return "****" }
+ let prefix = String(key.prefix(6))
+ let suffix = String(key.suffix(4))
+ return "\(prefix)...\(suffix)"
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let usedPercent: Double
+ let resetDescription: String
+
+ if let msg = self.statusMessage {
+ usedPercent = 100
+ resetDescription = msg
+ } else if self.limitRequests > 0 {
+ let used = max(0, self.limitRequests - self.remainingRequests)
+ usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100))
+ resetDescription = "\(used)/\(self.limitRequests) requests"
+ } else if self.apiKeyValid {
+ usedPercent = 0
+ resetDescription = "Active — check dashboard for details"
+ } else {
+ usedPercent = 0
+ resetDescription = "No usage data"
+ }
+
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: self.resetTime,
+ resetDescription: resetDescription)
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .aigocode,
+ accountEmail: Self.maskedKey(self.apiKey),
+ accountOrganization: nil,
+ loginMethod: "API")
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
+
+public enum AigoCodeUsageError: LocalizedError, Sendable {
+ case missingCredentials
+ case networkError(String)
+ case apiError(Int, String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Missing AigoCode API key (AIGOCODE_API_KEY)."
+ case let .networkError(message):
+ "AigoCode network error: \(message)"
+ case let .apiError(code, message):
+ "AigoCode API error (\(code)): \(message)"
+ case let .parseFailed(message):
+ "Failed to parse AigoCode response: \(message)"
+ }
+ }
+}
+
+public struct AigoCodeUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.aigocodeUsage)
+
+ private static let apiURL = URL(string: "https://api.aigocode.com/v1/messages")!
+
+ public static func fetchUsage(apiKey: String) async throws -> AigoCodeUsageSnapshot {
+ guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw AigoCodeUsageError.missingCredentials
+ }
+
+ var request = URLRequest(url: self.apiURL)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 15
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
+ request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
+
+ let body: [String: Any] = [
+ "model": "anthropic/claude-sonnet-4-5",
+ "max_tokens": 1,
+ "messages": [
+ ["role": "user", "content": "hi"],
+ ] as [[String: Any]],
+ ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw AigoCodeUsageError.networkError("Invalid response")
+ }
+
+ // 403 with INSUFFICIENT_BALANCE means the key is valid but account has no credits
+ if httpResponse.statusCode == 403 {
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let code = json["code"] as? String, code == "INSUFFICIENT_BALANCE"
+ {
+ return AigoCodeUsageSnapshot(
+ remainingRequests: 0,
+ limitRequests: 0,
+ resetTime: nil,
+ updatedAt: Date(),
+ apiKeyValid: true,
+ totalTokens: nil,
+ statusMessage: "Insufficient balance",
+ apiKey: apiKey)
+ }
+ }
+
+ // Accept both 200 (success) and 429 (rate limited).
+ guard httpResponse.statusCode == 200 || httpResponse.statusCode == 429 else {
+ let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data)
+ Self.log.error("AigoCode API returned \(httpResponse.statusCode): \(summary)")
+ throw AigoCodeUsageError.apiError(httpResponse.statusCode, summary)
+ }
+
+ let headers = httpResponse.allHeaderFields
+ let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests")
+ let limit = Self.intHeader(headers, "x-ratelimit-limit-requests")
+ let resetString = headers["x-ratelimit-reset-requests"] as? String
+
+ let resetTime: Date? = resetString.flatMap(Self.parseResetTime)
+
+ var totalTokens: Int?
+ if remaining == nil, limit == nil,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let usage = json["usage"] as? [String: Any]
+ {
+ totalTokens = usage["total_tokens"] as? Int
+ }
+
+ let snapshot = AigoCodeUsageSnapshot(
+ remainingRequests: remaining ?? 0,
+ limitRequests: limit ?? 0,
+ resetTime: resetTime,
+ updatedAt: Date(),
+ apiKeyValid: httpResponse.statusCode == 200,
+ totalTokens: totalTokens,
+ apiKey: apiKey)
+
+ Self.log.debug(
+ "AigoCode usage parsed remaining=\(snapshot.remainingRequests) limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid)") // swiftlint:disable:this line_length
+
+ return snapshot
+ }
+
+ private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? {
+ if let value = headers[name] as? String, let int = Int(value) {
+ return int
+ }
+ if let value = headers[name.lowercased()] as? String, let int = Int(value) {
+ return int
+ }
+ // Case-insensitive search
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.lowercased() == name.lowercased(),
+ let valStr = val as? String,
+ let int = Int(valStr)
+ {
+ return int
+ }
+ }
+ return nil
+ }
+
+ /// Parse reset time from header value like "1d2h3m4s" or "30s" or ISO 8601.
+ private static func parseResetTime(_ value: String) -> Date? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return nil }
+
+ // Try ISO 8601 first
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = isoFormatter.date(from: trimmed) { return date }
+ let isoFallback = ISO8601DateFormatter()
+ isoFallback.formatOptions = [.withInternetDateTime]
+ if let date = isoFallback.date(from: trimmed) { return date }
+
+ // Try duration format like "1d2h3m4s" or "30s"
+ var seconds: TimeInterval = 0
+ let pattern = /(\d+)([dhms])/
+ for match in trimmed.matches(of: pattern) {
+ guard let num = Double(match.1) else { continue }
+ switch match.2 {
+ case "d": seconds += num * 86400
+ case "h": seconds += num * 3600
+ case "m": seconds += num * 60
+ case "s": seconds += num
+ default: break
+ }
+ }
+ if seconds > 0 {
+ return Date().addingTimeInterval(seconds)
+ }
+
+ // Try plain integer as seconds
+ if let secs = TimeInterval(trimmed) {
+ return Date().addingTimeInterval(secs)
+ }
+
+ return nil
+ }
+
+ private static func apiErrorSummary(statusCode: Int, data: Data) -> String {
+ guard let root = try? JSONSerialization.jsonObject(with: data),
+ let json = root as? [String: Any]
+ else {
+ if let text = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !text.isEmpty
+ {
+ return self.compactText(text)
+ }
+ return "Unexpected response body (\(data.count) bytes)."
+ }
+
+ if let message = json["message"] as? String {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ if let error = json["error"] as? [String: Any],
+ let message = error["message"] as? String
+ {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ return "HTTP \(statusCode) (\(data.count) bytes)."
+ }
+
+ private static func compactText(_ text: String, maxLength: Int = 200) -> String {
+ let collapsed = text
+ .components(separatedBy: .newlines)
+ .joined(separator: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if collapsed.count <= maxLength { return collapsed }
+ let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength)
+ return "\(collapsed[.. [AntigravityModelQuota] {
var ordered: [AntigravityModelQuota] = []
- if let claude = models.first(where: { Self.isClaudeWithoutThinking($0.label) }) {
- ordered.append(claude)
+ if let pro = models.first(where: { Self.isGeminiProLow($0.label) }) {
+ ordered.append(pro)
}
- if let pro = models.first(where: { Self.isGeminiProLow($0.label) }),
- !ordered.contains(where: { $0.label == pro.label })
+ if let claude = models.first(where: { Self.isClaudeWithoutThinking($0.label) }),
+ !ordered.contains(where: { $0.label == claude.label })
{
- ordered.append(pro)
+ ordered.append(claude)
}
if let flash = models.first(where: { Self.isGeminiFlash($0.label) }),
!ordered.contains(where: { $0.label == flash.label })
@@ -108,7 +108,7 @@ public enum AntigravityStatusProbeError: LocalizedError, Sendable, Equatable {
public var errorDescription: String? {
switch self {
case .notRunning:
- "Antigravity language server not detected. Launch Antigravity and retry."
+ "Antigravity language server not detected. Launch Antigravity and retry. If behind a proxy, ensure Antigravity can reach its servers (set http_proxy/https_proxy or enable system proxy)." // swiftlint:disable:this line_length
case .missingCSRFToken:
"Antigravity CSRF token not found. Restart Antigravity and retry."
case let .portDetectionFailed(message):
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
new file mode 100644
index 000000000..b9beba0f5
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
@@ -0,0 +1,73 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum DoubaoProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .doubao,
+ metadata: ProviderMetadata(
+ id: .doubao,
+ displayName: "Doubao",
+ sessionLabel: "Requests",
+ weeklyLabel: "Monthly",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Doubao usage",
+ cliName: "doubao",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: nil,
+ dashboardURL: "https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=subscribe",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .doubao,
+ iconResourceName: "ProviderIcon-doubao",
+ color: ProviderColor(red: 51 / 255, green: 112 / 255, blue: 255 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Doubao cost summary is not available." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DoubaoAPIFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "doubao",
+ aliases: ["volcengine", "ark", "bytedance"],
+ versionDetector: nil))
+ }
+}
+
+struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "doubao.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ Self.resolveToken(environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = Self.resolveToken(environment: context.env) else {
+ throw DoubaoUsageError.missingCredentials
+ }
+ let usage = try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey)
+ let accumulated = await LocalUsageTracker.shared.record(
+ provider: .doubao,
+ remaining: usage.remainingRequests,
+ limit: usage.limitRequests)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(accumulated: accumulated),
+ sourceLabel: "api")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveToken(environment: [String: String]) -> String? {
+ ProviderTokenResolver.doubaoToken(environment: environment)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift
new file mode 100644
index 000000000..43568d4a5
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+public struct DoubaoSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKeys = [
+ "ARK_API_KEY",
+ "VOLCENGINE_API_KEY",
+ "DOUBAO_API_KEY",
+ ]
+
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ for key in self.apiKeyEnvironmentKeys {
+ guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !raw.isEmpty
+ else {
+ continue
+ }
+ let cleaned = Self.cleaned(raw)
+ if !cleaned.isEmpty {
+ return cleaned
+ }
+ }
+ return nil
+ }
+
+ private static func cleaned(_ raw: String) -> String {
+ var value = raw
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
new file mode 100644
index 000000000..0979170dd
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -0,0 +1,299 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct DoubaoUsageSnapshot: Sendable {
+ public let remainingRequests: Int
+ public let limitRequests: Int
+ public let resetTime: Date?
+ public let updatedAt: Date
+ public let apiKeyValid: Bool
+ public let totalTokens: Int?
+ public init(
+ remainingRequests: Int,
+ limitRequests: Int,
+ resetTime: Date?,
+ updatedAt: Date,
+ apiKeyValid: Bool = false,
+ totalTokens: Int? = nil)
+ {
+ self.remainingRequests = remainingRequests
+ self.limitRequests = limitRequests
+ self.resetTime = resetTime
+ self.updatedAt = updatedAt
+ self.apiKeyValid = apiKeyValid
+ self.totalTokens = totalTokens
+ }
+
+ public func toUsageSnapshot(
+ accumulated: LocalUsageTracker.AccumulatedUsage? = nil) -> UsageSnapshot
+ {
+ let usedPercent: Double
+ let resetDescription: String
+
+ if self.limitRequests > 0 {
+ let used = max(0, self.limitRequests - self.remainingRequests)
+ usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100))
+ resetDescription = "\(used)/\(self.limitRequests) requests"
+ } else if self.apiKeyValid {
+ usedPercent = 0
+ resetDescription = "Active — check dashboard for details"
+ } else {
+ usedPercent = 0
+ resetDescription = "No usage data"
+ }
+
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: self.resetTime,
+ resetDescription: resetDescription)
+
+ var secondary: RateWindow?
+ if let acc = accumulated, acc.weeklyLimit > 0 {
+ let weekPercent = min(100, max(0, Double(acc.weeklyRequests) / Double(acc.weeklyLimit) * 100))
+ secondary = RateWindow(
+ usedPercent: weekPercent,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: nil,
+ resetDescription: "\(acc.weeklyRequests)/\(acc.weeklyLimit) weekly")
+ }
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .doubao,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
+
+public enum DoubaoUsageError: LocalizedError, Sendable {
+ case missingCredentials
+ case networkError(String)
+ case apiError(Int, String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Missing Doubao API key (ARK_API_KEY)."
+ case let .networkError(message):
+ "Doubao network error: \(message)"
+ case let .apiError(code, message):
+ "Doubao API error (\(code)): \(message)"
+ case let .parseFailed(message):
+ "Failed to parse Doubao response: \(message)"
+ }
+ }
+}
+
+public struct DoubaoUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.doubaoUsage)
+ private static let apiURL = URL(string: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions")!
+
+ /// Models to probe, ordered by likelihood. We try multiple models because
+ /// different key types may not have access to every model.
+ private static let probeModels = [
+ "doubao-seed-2.0-code",
+ "doubao-1.5-pro-32k",
+ "doubao-lite-32k",
+ ]
+
+ public static func fetchUsage(apiKey: String) async throws -> DoubaoUsageSnapshot {
+ guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw DoubaoUsageError.missingCredentials
+ }
+
+ var lastError: Error?
+ for model in self.probeModels {
+ do {
+ return try await self.probe(apiKey: apiKey, model: model)
+ } catch let error as DoubaoUsageError {
+ if case let .apiError(code, _) = error, code == 404 || code == 403 {
+ Self.log.debug("Doubao probe model \(model) unavailable (\(code)), trying next")
+ lastError = error
+ continue
+ }
+ throw error
+ }
+ }
+ throw lastError ?? DoubaoUsageError.apiError(0, "All probe models failed")
+ }
+
+ private static func probe(apiKey: String, model: String) async throws -> DoubaoUsageSnapshot {
+ var request = URLRequest(url: self.apiURL)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 30
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+
+ let body: [String: Any] = [
+ "model": model,
+ "max_tokens": 1,
+ "messages": [
+ ["role": "user", "content": "hi"],
+ ] as [[String: Any]],
+ ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw DoubaoUsageError.networkError("Invalid response")
+ }
+
+ // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers.
+ guard httpResponse.statusCode == 200 || httpResponse.statusCode == 429 else {
+ let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data)
+ Self.log.error("Doubao API returned \(httpResponse.statusCode): \(summary)")
+ throw DoubaoUsageError.apiError(httpResponse.statusCode, summary)
+ }
+
+ let headers = httpResponse.allHeaderFields
+ let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests")
+ let limit = Self.intHeader(headers, "x-ratelimit-limit-requests")
+ let resetString = Self.stringHeader(headers, "x-ratelimit-reset-requests")
+
+ let resetTime: Date? = resetString.flatMap(Self.parseResetTime)
+
+ var totalTokens: Int?
+ if remaining == nil, limit == nil,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let usage = json["usage"] as? [String: Any]
+ {
+ totalTokens = usage["total_tokens"] as? Int
+ }
+
+ // 429 means the key is valid but rate-limited; treat it as valid so the UI
+ // shows "Active" instead of "No usage data" when headers are absent.
+ let keyValid = httpResponse.statusCode == 200 || httpResponse.statusCode == 429
+
+ let snapshot = DoubaoUsageSnapshot(
+ remainingRequests: remaining ?? 0,
+ limitRequests: limit ?? 0,
+ resetTime: resetTime,
+ updatedAt: Date(),
+ apiKeyValid: keyValid,
+ totalTokens: totalTokens)
+
+ Self.log.debug(
+ "Doubao usage parsed remaining=\(snapshot.remainingRequests) limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid)") // swiftlint:disable:this line_length
+
+ return snapshot
+ }
+
+ private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? {
+ if let value = headers[name] as? String { return value }
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.caseInsensitiveCompare(name) == .orderedSame,
+ let valStr = val as? String
+ {
+ return valStr
+ }
+ }
+ return nil
+ }
+
+ private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? {
+ if let value = headers[name] as? String, let int = Int(value) {
+ return int
+ }
+ if let value = headers[name.lowercased()] as? String, let int = Int(value) {
+ return int
+ }
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.lowercased() == name.lowercased(),
+ let valStr = val as? String,
+ let int = Int(valStr)
+ {
+ return int
+ }
+ }
+ return nil
+ }
+
+ private static func parseResetTime(_ value: String) -> Date? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return nil }
+
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = isoFormatter.date(from: trimmed) { return date }
+ let isoFallback = ISO8601DateFormatter()
+ isoFallback.formatOptions = [.withInternetDateTime]
+ if let date = isoFallback.date(from: trimmed) { return date }
+
+ var seconds: TimeInterval = 0
+ let pattern = /(\d+)([dhms])/
+ for match in trimmed.matches(of: pattern) {
+ guard let num = Double(match.1) else { continue }
+ switch match.2 {
+ case "d": seconds += num * 86400
+ case "h": seconds += num * 3600
+ case "m": seconds += num * 60
+ case "s": seconds += num
+ default: break
+ }
+ }
+ if seconds > 0 {
+ return Date().addingTimeInterval(seconds)
+ }
+
+ if let secs = TimeInterval(trimmed) {
+ return Date().addingTimeInterval(secs)
+ }
+
+ return nil
+ }
+
+ private static func apiErrorSummary(statusCode: Int, data: Data) -> String {
+ guard let root = try? JSONSerialization.jsonObject(with: data),
+ let json = root as? [String: Any]
+ else {
+ if let text = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !text.isEmpty
+ {
+ return self.compactText(text)
+ }
+ return "Unexpected response body (\(data.count) bytes)."
+ }
+
+ if let error = json["error"] as? [String: Any],
+ let message = error["message"] as? String
+ {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ if let message = json["message"] as? String {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ return "HTTP \(statusCode) (\(data.count) bytes)."
+ }
+
+ private static func compactText(_ text: String, maxLength: Int = 200) -> String {
+ let collapsed = text
+ .components(separatedBy: .newlines)
+ .joined(separator: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if collapsed.count <= maxLength { return collapsed }
+ let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength)
+ return "\(collapsed[.. String? {
+ // Resolve symlinks recursively, collecting all intermediate paths
+ // (e.g. /usr/local/bin/gemini → /opt/homebrew/bin/gemini → ../Cellar/.../bin/gemini → ...)
let fm = FileManager.default
- var realPath = geminiPath
- if let resolved = try? fm.destinationOfSymbolicLink(atPath: geminiPath) {
+ var candidates: [String] = [binaryPath]
+ var current = binaryPath
+ var visited: Set = []
+ while true {
+ let canonical = (current as NSString).standardizingPath
+ if visited.contains(canonical) { break }
+ visited.insert(canonical)
+ guard let resolved = try? fm.destinationOfSymbolicLink(atPath: current) else { break }
if resolved.hasPrefix("/") {
- realPath = resolved
+ current = resolved
} else {
- realPath = (geminiPath as NSString).deletingLastPathComponent + "/" + resolved
+ current = ((current as NSString).deletingLastPathComponent as NSString)
+ .appendingPathComponent(resolved)
}
+ current = (current as NSString).standardizingPath
+ candidates.append(current)
}
// Navigate from bin/gemini to the oauth2.js file
- // Homebrew path: .../libexec/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js
- // Bun/npm path: .../node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js (sibling package)
- let binDir = (realPath as NSString).deletingLastPathComponent
- let baseDir = (binDir as NSString).deletingLastPathComponent
-
+ // Try from each resolved path in the symlink chain (deepest first)
let oauthSubpath =
"node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"
let nixShareSubpath =
"share/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"
let oauthFile = "dist/src/code_assist/oauth2.js"
- let possiblePaths = [
- // Homebrew nested structure
- "\(baseDir)/libexec/lib/\(oauthSubpath)",
- "\(baseDir)/lib/\(oauthSubpath)",
- // Nix package layout
- "\(baseDir)/\(nixShareSubpath)",
- // Bun/npm sibling structure: gemini-cli-core is a sibling to gemini-cli
- "\(baseDir)/../gemini-cli-core/\(oauthFile)",
- // npm nested inside gemini-cli
- "\(baseDir)/node_modules/@google/gemini-cli-core/\(oauthFile)",
- ]
-
- for path in possiblePaths {
- if let content = try? String(contentsOfFile: path, encoding: .utf8) {
- return self.parseOAuthCredentials(from: content)
+
+ for candidate in candidates.reversed() {
+ let binDir = (candidate as NSString).deletingLastPathComponent
+ let baseDir = (binDir as NSString).deletingLastPathComponent
+
+ let possiblePaths = [
+ // Homebrew nested structure
+ "\(baseDir)/libexec/lib/\(oauthSubpath)",
+ "\(baseDir)/lib/\(oauthSubpath)",
+ // Nix package layout
+ "\(baseDir)/\(nixShareSubpath)",
+ // Bun/npm sibling structure
+ "\(baseDir)/../gemini-cli-core/\(oauthFile)",
+ // npm nested inside gemini-cli
+ "\(baseDir)/node_modules/@google/gemini-cli-core/\(oauthFile)",
+ // Direct node_modules lookup from candidate
+ "\(binDir)/node_modules/@google/gemini-cli-core/\(oauthFile)",
+ ]
+
+ for path in possiblePaths {
+ if let content = try? String(contentsOfFile: path, encoding: .utf8) {
+ return content
+ }
}
}
diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiLocalStorageImporter.swift b/Sources/CodexBarCore/Providers/Kimi/KimiLocalStorageImporter.swift
new file mode 100644
index 000000000..f76b3c9b8
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Kimi/KimiLocalStorageImporter.swift
@@ -0,0 +1,177 @@
+#if os(macOS)
+import Foundation
+import SweetCookieKit
+
+/// Imports Kimi auth tokens from Chrome's localStorage.
+///
+/// Kimi stores `access_token` and `refresh_token` as JWTs in localStorage
+/// on `https://www.kimi.com`, not in cookies.
+public enum KimiLocalStorageImporter {
+ private static let log = CodexBarLog.logger(LogCategories.kimiCookie)
+
+ private static let origins = [
+ "https://www.kimi.com",
+ "https://kimi.com",
+ ]
+
+ /// The localStorage key that holds the JWT access token.
+ private static let accessTokenKey = "access_token"
+ private static let refreshTokenKey = "refresh_token"
+
+ public struct SessionInfo: Sendable {
+ public let accessToken: String
+ public let refreshToken: String?
+ public let sourceLabel: String
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) -> SessionInfo?
+ {
+ let log: (String) -> Void = { msg in
+ logger?("[kimi-storage] \(msg)")
+ self.log.debug(msg)
+ }
+
+ let candidates = self.chromeLocalStorageCandidates(browserDetection: browserDetection)
+ log("Found \(candidates.count) Chrome profile candidate(s)")
+
+ for candidate in candidates {
+ if let session = self.readKimiSession(from: candidate.levelDBURL, label: candidate.label, logger: log) {
+ return session
+ }
+ }
+
+ log("No Kimi access_token found in any browser profile")
+ return nil
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection()) -> Bool
+ {
+ self.importSession(browserDetection: browserDetection) != nil
+ }
+
+ // MARK: - LevelDB Reading
+
+ private static func readKimiSession(
+ from levelDBURL: URL,
+ label: String,
+ logger: ((String) -> Void)?) -> SessionInfo?
+ {
+ for origin in self.origins {
+ let entries = ChromiumLocalStorageReader.readEntries(
+ for: origin,
+ in: levelDBURL,
+ logger: logger)
+
+ var accessToken: String?
+ var refreshToken: String?
+
+ for entry in entries {
+ let value = entry.value.trimmingCharacters(in: .controlCharacters)
+ guard !value.isEmpty else { continue }
+
+ if entry.key == self.accessTokenKey, value.hasPrefix("eyJ") {
+ accessToken = value
+ } else if entry.key == self.refreshTokenKey, value.hasPrefix("eyJ") {
+ refreshToken = value
+ }
+ }
+
+ if let accessToken {
+ logger?("Found Kimi access_token in \(label) (origin: \(origin))")
+ return SessionInfo(
+ accessToken: accessToken,
+ refreshToken: refreshToken,
+ sourceLabel: label)
+ }
+ }
+
+ // Fallback: text scan
+ let textEntries = ChromiumLocalStorageReader.readTextEntries(
+ in: levelDBURL,
+ logger: logger)
+
+ var accessToken: String?
+ var refreshToken: String?
+
+ for entry in textEntries {
+ let value = entry.value.trimmingCharacters(in: .controlCharacters)
+ guard !value.isEmpty, value.hasPrefix("eyJ") else { continue }
+
+ if entry.key.hasSuffix(self.accessTokenKey) {
+ accessToken = value
+ } else if entry.key.hasSuffix(self.refreshTokenKey) {
+ refreshToken = value
+ }
+ }
+
+ if let accessToken {
+ logger?("Found Kimi access_token in \(label) (text scan)")
+ return SessionInfo(
+ accessToken: accessToken,
+ refreshToken: refreshToken,
+ sourceLabel: label)
+ }
+
+ return nil
+ }
+
+ // MARK: - Chrome Profile Discovery
+
+ private struct LocalStorageCandidate {
+ let label: String
+ let levelDBURL: URL
+ }
+
+ private static func chromeLocalStorageCandidates(
+ browserDetection: BrowserDetection) -> [LocalStorageCandidate]
+ {
+ let browsers: [Browser] = [
+ .chrome,
+ .chromeBeta,
+ .chromeCanary,
+ .arc,
+ .arcBeta,
+ .arcCanary,
+ .chromium,
+ ]
+
+ let installedBrowsers = browsers.browsersWithProfileData(using: browserDetection)
+ let roots = ChromiumProfileLocator
+ .roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories())
+ .map { (url: $0.url, labelPrefix: $0.labelPrefix) }
+
+ var candidates: [LocalStorageCandidate] = []
+ for root in roots {
+ candidates.append(contentsOf: self.profileCandidates(root: root.url, labelPrefix: root.labelPrefix))
+ }
+ return candidates
+ }
+
+ private static func profileCandidates(root: URL, labelPrefix: String) -> [LocalStorageCandidate] {
+ guard let entries = try? FileManager.default.contentsOfDirectory(
+ at: root,
+ includingPropertiesForKeys: [.isDirectoryKey],
+ options: [.skipsHiddenFiles])
+ else { return [] }
+
+ let profileDirs = entries.filter { url in
+ guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else {
+ return false
+ }
+ let name = url.lastPathComponent
+ return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-")
+ }
+ .sorted { $0.lastPathComponent < $1.lastPathComponent }
+
+ return profileDirs.compactMap { dir in
+ let levelDBURL = dir.appendingPathComponent("Local Storage").appendingPathComponent("leveldb")
+ guard FileManager.default.fileExists(atPath: levelDBURL.path) else { return nil }
+ let label = "\(labelPrefix) \(dir.lastPathComponent)"
+ return LocalStorageCandidate(label: label, levelDBURL: levelDBURL)
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift
index 711c20bc8..46c7e4417 100644
--- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift
@@ -57,7 +57,8 @@ struct KimiWebFetchStrategy: ProviderFetchStrategy {
#if os(macOS)
if context.settings?.kimi?.cookieSource != .off {
- return KimiCookieImporter.hasSession()
+ if KimiCookieImporter.hasSession() { return true }
+ if KimiLocalStorageImporter.hasSession() { return true }
}
#endif
@@ -90,6 +91,7 @@ struct KimiWebFetchStrategy: ProviderFetchStrategy {
// Try browser cookie import when auto mode is enabled
#if os(macOS)
if context.settings?.kimi?.cookieSource != .off {
+ // Try cookies first (legacy kimi-auth cookie)
do {
let session = try KimiCookieImporter.importSession()
if let token = session.authToken {
@@ -98,6 +100,11 @@ struct KimiWebFetchStrategy: ProviderFetchStrategy {
} catch {
// No browser cookies found
}
+
+ // Try localStorage (current: access_token JWT)
+ if let lsSession = KimiLocalStorageImporter.importSession() {
+ return lsSession.accessToken
+ }
}
#endif
diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift
index c6d6c2a2e..7a06a6385 100644
--- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift
@@ -75,7 +75,8 @@ public struct KimiUsageFetcher: Sendable {
return KimiUsageSnapshot(
weekly: codingUsage.detail,
rateLimit: codingUsage.limits?.first?.detail,
- updatedAt: now)
+ updatedAt: now,
+ authToken: authToken)
}
private static func decodeSessionInfo(from jwt: String) -> SessionInfo? {
diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift
index c39e1602a..bcb94b477 100644
--- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift
@@ -4,11 +4,13 @@ public struct KimiUsageSnapshot: Sendable {
public let weekly: KimiUsageDetail
public let rateLimit: KimiUsageDetail?
public let updatedAt: Date
+ public let authToken: String?
- public init(weekly: KimiUsageDetail, rateLimit: KimiUsageDetail?, updatedAt: Date) {
+ public init(weekly: KimiUsageDetail, rateLimit: KimiUsageDetail?, updatedAt: Date, authToken: String? = nil) {
self.weekly = weekly
self.rateLimit = rateLimit
self.updatedAt = updatedAt
+ self.authToken = authToken
}
private static func parseDate(_ dateString: String) -> Date? {
@@ -66,11 +68,21 @@ extension KimiUsageSnapshot {
resetDescription: "Rate: \(rateUsed)/\(rateLimitValue) per 5 hours")
}
+ let maskedKey: String? = {
+ guard let key = self.authToken, !key.isEmpty else { return nil }
+ if key.count <= 8 { return "****" }
+ return "\(key.prefix(6))...\(key.suffix(4))"
+ }()
+ let plan: String? = {
+ guard let key = self.authToken else { return nil }
+ if key.hasPrefix("sk-kimi-") { return "API" }
+ return "Web"
+ }()
let identity = ProviderIdentitySnapshot(
providerID: .kimi,
- accountEmail: nil,
+ accountEmail: maskedKey,
accountOrganization: nil,
- loginMethod: nil)
+ loginMethod: plan)
return UsageSnapshot(
primary: weeklyWindow,
diff --git a/Sources/CodexBarCore/Providers/LocalUsageTracker.swift b/Sources/CodexBarCore/Providers/LocalUsageTracker.swift
new file mode 100644
index 000000000..cc7820dcb
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LocalUsageTracker.swift
@@ -0,0 +1,162 @@
+import Foundation
+
+/// Tracks API request consumption locally by recording rate-limit snapshots over time.
+/// Computes weekly (7-day) and monthly (30-day) accumulated usage from deltas between samples.
+/// Used by providers (Doubao, Qwen) that only expose rate-limit headers without dedicated usage APIs.
+public actor LocalUsageTracker {
+ public static let shared = LocalUsageTracker()
+
+ private static let sampleInterval: TimeInterval = 60
+ private static let retentionDays: TimeInterval = 31 * 24 * 60 * 60
+ private static let weekSeconds: TimeInterval = 7 * 24 * 60 * 60
+ private static let monthSeconds: TimeInterval = 30 * 24 * 60 * 60
+
+ private var records: [String: [Sample]] = [:]
+ private var loaded = false
+
+ private struct Sample: Codable, Sendable {
+ let timestamp: Date
+ let remaining: Int
+ let limit: Int
+ }
+
+ public struct AccumulatedUsage: Sendable {
+ public let weeklyRequests: Int
+ public let monthlyRequests: Int
+ public let weeklyLimit: Int
+ public let monthlyLimit: Int
+ }
+
+ private init() {}
+
+ /// Record a rate-limit sample and return accumulated weekly/monthly usage.
+ public func record(
+ provider: UsageProvider,
+ remaining: Int,
+ limit: Int,
+ now: Date = Date()) -> AccumulatedUsage
+ {
+ self.ensureLoaded()
+ let key = provider.rawValue
+
+ var samples = self.records[key] ?? []
+
+ // Throttle: skip if last sample is too recent and values unchanged
+ if let last = samples.last {
+ let elapsed = now.timeIntervalSince(last.timestamp)
+ if elapsed < Self.sampleInterval, last.remaining == remaining, last.limit == limit {
+ return self.computeUsage(samples: samples, limit: limit, now: now)
+ }
+ }
+
+ samples.append(Sample(timestamp: now, remaining: remaining, limit: limit))
+
+ // Prune old samples
+ let cutoff = now.addingTimeInterval(-Self.retentionDays)
+ samples.removeAll { $0.timestamp < cutoff }
+
+ self.records[key] = samples
+ self.persist()
+
+ return self.computeUsage(samples: samples, limit: limit, now: now)
+ }
+
+ /// Get accumulated usage without recording a new sample.
+ public func usage(for provider: UsageProvider) -> AccumulatedUsage? {
+ self.ensureLoaded()
+ guard let samples = self.records[provider.rawValue], !samples.isEmpty else { return nil }
+ let limit = samples.last?.limit ?? 0
+ return self.computeUsage(samples: samples, limit: limit, now: Date())
+ }
+
+ // MARK: - Computation
+
+ private func computeUsage(samples: [Sample], limit: Int, now: Date) -> AccumulatedUsage {
+ let weekCutoff = now.addingTimeInterval(-Self.weekSeconds)
+ let monthCutoff = now.addingTimeInterval(-Self.monthSeconds)
+
+ let weeklyRequests = Self.sumConsumption(
+ samples: samples.filter { $0.timestamp >= weekCutoff })
+ let monthlyRequests = Self.sumConsumption(
+ samples: samples.filter { $0.timestamp >= monthCutoff })
+
+ // Estimate limits: daily limit * 7 or * 30
+ // We use the current window limit as a proxy for daily capacity
+ let weeklyLimit = limit > 0 ? limit * 7 : 0
+ let monthlyLimit = limit > 0 ? limit * 30 : 0
+
+ return AccumulatedUsage(
+ weeklyRequests: weeklyRequests,
+ monthlyRequests: monthlyRequests,
+ weeklyLimit: weeklyLimit,
+ monthlyLimit: monthlyLimit)
+ }
+
+ /// Sum consumption from consecutive samples by tracking remaining-count drops.
+ /// When remaining increases (reset occurred), we don't count that as negative consumption.
+ private static func sumConsumption(samples: [Sample]) -> Int {
+ guard samples.count >= 2 else { return 0 }
+
+ var total = 0
+ for i in 1.. URL {
+ let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? FileManager.default.homeDirectoryForCurrentUser
+ return root
+ .appendingPathComponent("CodexBar", isDirectory: true)
+ .appendingPathComponent("local-usage-tracker.json", isDirectory: false)
+ }
+
+ private static func readFromDisk() -> [String: [Sample]] {
+ guard let data = try? Data(contentsOf: fileURL()),
+ let decoded = try? JSONDecoder.iso8601Decoder.decode([String: [Sample]].self, from: data)
+ else {
+ return [:]
+ }
+ return decoded
+ }
+
+ private func persist() {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+
+ guard let data = try? encoder.encode(self.records) else { return }
+ let url = Self.fileURL()
+ let directory = url.deletingLastPathComponent()
+ do {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try data.write(to: url, options: [.atomic])
+ } catch {
+ // Best-effort; ignore write failures.
+ }
+ }
+}
+
+extension JSONDecoder {
+ fileprivate static let iso8601Decoder: JSONDecoder = {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }()
+}
diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift
new file mode 100644
index 000000000..97ccea8f0
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift
@@ -0,0 +1,239 @@
+import Foundation
+
+enum MiMoCookieHeader {
+ static let requiredCookieNames: Set = [
+ "api-platform_serviceToken",
+ "userId",
+ ]
+ static let knownCookieNames: Set = requiredCookieNames.union([
+ "api-platform_ph",
+ "api-platform_slh",
+ ])
+
+ static func normalizedHeader(from raw: String?) -> String? {
+ guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil }
+ let pairs = CookieHeaderNormalizer.pairs(from: normalized)
+ guard !pairs.isEmpty else { return nil }
+
+ var byName: [String: String] = [:]
+ for pair in pairs {
+ let name = pair.name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let value = pair.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard self.knownCookieNames.contains(name), !value.isEmpty else { continue }
+ byName[name] = value
+ }
+
+ guard self.requiredCookieNames.isSubset(of: Set(byName.keys)) else { return nil }
+ return byName.keys.sorted().compactMap { name in
+ guard let value = byName[name] else { return nil }
+ return "\(name)=\(value)"
+ }.joined(separator: "; ")
+ }
+
+ #if os(macOS)
+ static func header(from cookies: [HTTPCookie]) -> String? {
+ let requestURL = URL(string: "https://platform.xiaomimimo.com/api/v1/balance")!
+ var byName: [String: HTTPCookie] = [:]
+ for cookie in cookies {
+ guard self.knownCookieNames.contains(cookie.name) else { continue }
+ guard !cookie.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue }
+ if let expiry = cookie.expiresDate, expiry < Date() { continue }
+ guard Self.matchesRequestURL(cookie: cookie, url: requestURL) else { continue }
+
+ if let existing = byName[cookie.name] {
+ if Self.cookieSortKey(for: cookie) >= Self.cookieSortKey(for: existing) {
+ byName[cookie.name] = cookie
+ }
+ } else {
+ byName[cookie.name] = cookie
+ }
+ }
+
+ guard self.requiredCookieNames.isSubset(of: Set(byName.keys)) else { return nil }
+ return byName.keys.sorted().compactMap { name in
+ guard let cookie = byName[name] else { return nil }
+ return "\(cookie.name)=\(cookie.value)"
+ }.joined(separator: "; ")
+ }
+
+ private static func matchesRequestURL(cookie: HTTPCookie, url: URL) -> Bool {
+ guard let host = url.host else { return false }
+ let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
+ guard !normalizedDomain.isEmpty else { return false }
+ guard host == normalizedDomain || host.hasSuffix(".\(normalizedDomain)") else { return false }
+
+ let cookiePath = cookie.path.isEmpty ? "/" : cookie.path
+ let requestPath = url.path.isEmpty ? "/" : url.path
+ if requestPath == cookiePath {
+ return true
+ }
+ guard requestPath.hasPrefix(cookiePath) else { return false }
+ guard cookiePath != "/" else { return true }
+ if cookiePath.hasSuffix("/") {
+ return true
+ }
+ guard
+ let boundaryIndex = requestPath.index(
+ cookiePath.startIndex,
+ offsetBy: cookiePath.count,
+ limitedBy: requestPath.endIndex),
+ boundaryIndex < requestPath.endIndex
+ else {
+ return true
+ }
+ return requestPath[boundaryIndex] == "/"
+ }
+
+ private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) {
+ let pathLength = cookie.path.count
+ let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
+ let domainLength = normalizedDomain.count
+ let expiry = cookie.expiresDate ?? .distantPast
+ return (pathLength, domainLength, expiry)
+ }
+ #endif
+}
+
+#if os(macOS)
+import SweetCookieKit
+
+private let miMoCookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.mimo]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+public enum MiMoCookieImporter {
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = [
+ "platform.xiaomimimo.com",
+ "xiaomimimo.com",
+ ]
+
+ public struct SessionInfo: Sendable {
+ public let cookieHeader: String
+ public let sourceLabel: String
+
+ public init(cookieHeader: String, sourceLabel: String) {
+ self.cookieHeader = cookieHeader
+ self.sourceLabel = sourceLabel
+ }
+ }
+
+ nonisolated(unsafe) static var importSessionsOverrideForTesting:
+ ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])?
+
+ public static func importSessions(
+ browserDetection: BrowserDetection,
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ if let override = self.importSessionsOverrideForTesting {
+ return try override(browserDetection, logger)
+ }
+
+ let log: (String) -> Void = { msg in logger?("[mimo-cookie] \(msg)") }
+ var sessions: [SessionInfo] = []
+ let installed = miMoCookieImportOrder.cookieImportCandidates(using: browserDetection)
+ let labels = installed.map(\.displayName).joined(separator: ", ")
+ log("Cookie import candidates: \(labels)")
+
+ for browserSource in installed {
+ do {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let sources = try Self.cookieClient.records(
+ matching: query,
+ in: browserSource,
+ logger: log)
+ sessions.append(contentsOf: self.sessionInfos(from: sources, origin: query.origin))
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
+ }
+ }
+
+ return sessions
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection,
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ (try? self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty == false) ?? false
+ }
+
+ static func sessionInfos(
+ from sources: [BrowserCookieStoreRecords],
+ origin: BrowserCookieOriginStrategy = .domainBased) -> [SessionInfo]
+ {
+ let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id })
+ let sortedGroups = grouped.values.sorted { lhs, rhs in
+ self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs)
+ }
+
+ var sessions: [SessionInfo] = []
+ for group in sortedGroups where !group.isEmpty {
+ let label = self.mergedLabel(for: group)
+ let mergedRecords = self.mergeRecords(group)
+ guard !mergedRecords.isEmpty else { continue }
+ let cookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: origin)
+ guard let cookieHeader = MiMoCookieHeader.header(from: cookies) else {
+ continue
+ }
+ sessions.append(SessionInfo(cookieHeader: cookieHeader, sourceLabel: label))
+ }
+ return sessions
+ }
+
+ private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String {
+ guard let base = sources.map(\.label).min() else {
+ return "Unknown"
+ }
+ if base.hasSuffix(" (Network)") {
+ return String(base.dropLast(" (Network)".count))
+ }
+ return base
+ }
+
+ private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] {
+ let sortedSources = sources.sorted { lhs, rhs in
+ self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind)
+ }
+ var mergedByKey: [String: BrowserCookieRecord] = [:]
+ for source in sortedSources {
+ for record in source.records {
+ let key = self.recordKey(record)
+ if let existing = mergedByKey[key] {
+ if self.shouldReplace(existing: existing, candidate: record) {
+ mergedByKey[key] = record
+ }
+ } else {
+ mergedByKey[key] = record
+ }
+ }
+ }
+ return Array(mergedByKey.values)
+ }
+
+ private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int {
+ switch kind {
+ case .network: 0
+ case .primary: 1
+ case .safari: 2
+ }
+ }
+
+ private static func recordKey(_ record: BrowserCookieRecord) -> String {
+ "\(record.name)|\(record.domain)|\(record.path)"
+ }
+
+ private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool {
+ switch (existing.expires, candidate.expires) {
+ case let (lhs?, rhs?):
+ rhs > lhs
+ case (nil, .some):
+ true
+ case (.some, nil):
+ false
+ case (nil, nil):
+ false
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift
new file mode 100644
index 000000000..42e3eb6cc
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift
@@ -0,0 +1,178 @@
+import CodexBarMacroSupport
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+#endif
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum MiMoProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ #if os(macOS)
+ let browserOrder: BrowserCookieImportOrder = [
+ .chrome,
+ .chromeBeta,
+ .chromeCanary,
+ ]
+ #else
+ let browserOrder: BrowserCookieImportOrder? = nil
+ #endif
+
+ return ProviderDescriptor(
+ id: .mimo,
+ metadata: ProviderMetadata(
+ id: .mimo,
+ displayName: "Xiaomi MiMo",
+ sessionLabel: "Credits",
+ weeklyLabel: "Window",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: true,
+ creditsHint: "Token plan credits usage.",
+ toggleTitle: "Show Xiaomi MiMo token plan & balance",
+ cliName: "mimo",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: browserOrder,
+ dashboardURL: "https://platform.xiaomimimo.com/#/console/balance",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .mimo,
+ iconResourceName: "ProviderIcon-mimo",
+ color: ProviderColor(red: 1.0, green: 105 / 255, blue: 0)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Xiaomi MiMo cost summary is not supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MiMoWebFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "mimo",
+ aliases: ["xiaomi-mimo"],
+ versionDetector: nil))
+ }
+}
+
+struct MiMoWebFetchStrategy: ProviderFetchStrategy {
+ let id: String = "mimo.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ guard context.settings?.mimo?.cookieSource != .off else { return false }
+ if context.settings?.mimo?.cookieSource == .manual {
+ return Self.resolveManualCookieHeader(context: context) != nil
+ }
+ if Self.resolveManualCookieHeader(context: context) != nil {
+ return true
+ }
+
+ #if os(macOS)
+ if let cached = CookieHeaderCache.load(provider: .mimo),
+ MiMoCookieHeader.normalizedHeader(from: cached.cookieHeader) != nil
+ {
+ return true
+ }
+ return MiMoCookieImporter.hasSession(browserDetection: context.browserDetection)
+ #else
+ return false
+ #endif
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard context.settings?.mimo?.cookieSource != .off else {
+ throw MiMoSettingsError.missingCookie
+ }
+ if context.settings?.mimo?.cookieSource == .manual {
+ guard let manualCookie = Self.resolveManualCookieHeader(context: context) else {
+ throw MiMoSettingsError.invalidCookie
+ }
+ let snapshot = try await MiMoUsageFetcher.fetchUsage(
+ cookieHeader: manualCookie,
+ environment: context.env)
+ return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web")
+ }
+ if let manualCookie = Self.resolveManualCookieHeader(context: context) {
+ let snapshot = try await MiMoUsageFetcher.fetchUsage(
+ cookieHeader: manualCookie,
+ environment: context.env)
+ return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web")
+ }
+
+ #if os(macOS)
+ var lastError: Error?
+
+ if let cached = CookieHeaderCache.load(provider: .mimo),
+ let cachedHeader = MiMoCookieHeader.normalizedHeader(from: cached.cookieHeader)
+ {
+ do {
+ let snapshot = try await MiMoUsageFetcher.fetchUsage(
+ cookieHeader: cachedHeader,
+ environment: context.env)
+ return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web")
+ } catch {
+ guard Self.shouldRetryNextSession(for: error) else {
+ throw error
+ }
+ CookieHeaderCache.clear(provider: .mimo)
+ lastError = error
+ }
+ }
+
+ let sessions = try MiMoCookieImporter.importSessions(browserDetection: context.browserDetection)
+ guard !sessions.isEmpty else {
+ if let lastError { throw lastError }
+ throw MiMoSettingsError.missingCookie
+ }
+
+ for session in sessions {
+ do {
+ let snapshot = try await MiMoUsageFetcher.fetchUsage(
+ cookieHeader: session.cookieHeader,
+ environment: context.env)
+ CookieHeaderCache.store(
+ provider: .mimo,
+ cookieHeader: session.cookieHeader,
+ sourceLabel: session.sourceLabel)
+ return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web")
+ } catch {
+ guard Self.shouldRetryNextSession(for: error) else {
+ throw error
+ }
+ lastError = error
+ continue
+ }
+ }
+
+ if let lastError { throw lastError }
+ throw MiMoSettingsError.missingCookie
+ #else
+ throw MiMoSettingsError.missingCookie
+ #endif
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveManualCookieHeader(context: ProviderFetchContext) -> String? {
+ guard context.settings?.mimo?.cookieSource == .manual else { return nil }
+ return MiMoCookieHeader.normalizedHeader(from: context.settings?.mimo?.manualCookieHeader)
+ }
+
+ private static func shouldRetryNextSession(for error: Error) -> Bool {
+ if error is DecodingError {
+ return true
+ }
+ guard let mimoError = error as? MiMoUsageError else {
+ return false
+ }
+ switch mimoError {
+ case .invalidCredentials, .loginRequired, .parseFailed:
+ return true
+ case .networkError:
+ return false
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift
new file mode 100644
index 000000000..1770c5525
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift
@@ -0,0 +1,263 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public enum MiMoSettingsError: LocalizedError, Sendable {
+ case missingCookie
+ case invalidCookie
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCookie:
+ "No Xiaomi MiMo browser session found. Log in at platform.xiaomimimo.com first."
+ case .invalidCookie:
+ "Xiaomi MiMo requires the api-platform_serviceToken and userId cookies."
+ }
+ }
+}
+
+public enum MiMoUsageError: LocalizedError, Sendable {
+ case invalidCredentials
+ case loginRequired
+ case parseFailed(String)
+ case networkError(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidCredentials:
+ "Xiaomi MiMo browser session expired. Log in again."
+ case .loginRequired:
+ "Xiaomi MiMo login required."
+ case let .parseFailed(message):
+ "Could not parse Xiaomi MiMo balance: \(message)"
+ case let .networkError(message):
+ "Xiaomi MiMo request failed: \(message)"
+ }
+ }
+}
+
+public enum MiMoSettingsReader {
+ public static let apiURLKey = "MIMO_API_URL"
+
+ public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
+ if let override = environment[self.apiURLKey],
+ let url = URL(string: override.trimmingCharacters(in: .whitespacesAndNewlines)),
+ let scheme = url.scheme, !scheme.isEmpty
+ {
+ return url
+ }
+ return URL(string: "https://platform.xiaomimimo.com/api/v1")!
+ }
+}
+
+public enum MiMoUsageFetcher {
+ private static let requestTimeout: TimeInterval = 15
+
+ public static func fetchUsage(
+ cookieHeader: String,
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ now: Date = Date()) async throws -> MiMoUsageSnapshot
+ {
+ guard let normalizedCookie = MiMoCookieHeader.normalizedHeader(from: cookieHeader) else {
+ throw MiMoSettingsError.invalidCookie
+ }
+
+ let balanceURL = MiMoSettingsReader.apiURL(environment: environment).appendingPathComponent("balance")
+ let tokenDetailURL = MiMoSettingsReader.apiURL(environment: environment)
+ .appendingPathComponent("tokenPlan/detail")
+ let tokenUsageURL = MiMoSettingsReader.apiURL(environment: environment)
+ .appendingPathComponent("tokenPlan/usage")
+
+ async let balanceData = self.fetchAuthenticated(url: balanceURL, cookie: normalizedCookie)
+ let tokenDetailData: Data? = try? await self.fetchAuthenticated(url: tokenDetailURL, cookie: normalizedCookie)
+ let tokenUsageData: Data? = try? await self.fetchAuthenticated(url: tokenUsageURL, cookie: normalizedCookie)
+
+ return try await self.parseCombinedSnapshot(
+ balanceData: balanceData,
+ tokenDetailData: tokenDetailData,
+ tokenUsageData: tokenUsageData,
+ now: now)
+ }
+
+ private static func fetchAuthenticated(
+ url: URL,
+ cookie: String,
+ environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> Data
+ {
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.timeoutInterval = Self.requestTimeout
+ request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
+ request.setValue(cookie, forHTTPHeaderField: "Cookie")
+ request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
+ request.setValue("UTC+01:00", forHTTPHeaderField: "x-timeZone")
+ request.setValue("https://platform.xiaomimimo.com", forHTTPHeaderField: "Origin")
+ request.setValue("https://platform.xiaomimimo.com/#/console/balance", forHTTPHeaderField: "Referer")
+ request.setValue(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
+ forHTTPHeaderField: "User-Agent")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw MiMoUsageError.networkError("Invalid response")
+ }
+
+ switch httpResponse.statusCode {
+ case 200:
+ break
+ case 401:
+ throw MiMoUsageError.loginRequired
+ case 403:
+ throw MiMoUsageError.invalidCredentials
+ default:
+ throw MiMoUsageError.networkError("HTTP \(httpResponse.statusCode)")
+ }
+
+ return data
+ }
+
+ static func parseCombinedSnapshot(
+ balanceData: Data,
+ tokenDetailData: Data?,
+ tokenUsageData: Data?,
+ now: Date = Date()) throws -> MiMoUsageSnapshot
+ {
+ let balanceSnapshot = try self.parseUsageSnapshot(from: balanceData, now: now)
+ let planDetail: (planCode: String?, periodEnd: Date?, expired: Bool) = {
+ guard let data = tokenDetailData, let result = try? self.parseTokenPlanDetail(from: data) else {
+ return (planCode: nil, periodEnd: nil, expired: false)
+ }
+ return result
+ }()
+ let planUsage: (used: Int, limit: Int, percent: Double) = {
+ guard let data = tokenUsageData, let result = try? self.parseTokenPlanUsage(from: data) else {
+ return (used: 0, limit: 0, percent: 0)
+ }
+ return result
+ }()
+
+ return MiMoUsageSnapshot(
+ balance: balanceSnapshot.balance,
+ currency: balanceSnapshot.currency,
+ planCode: planDetail.planCode,
+ planPeriodEnd: planDetail.periodEnd,
+ planExpired: planDetail.expired,
+ tokenUsed: planUsage.used,
+ tokenLimit: planUsage.limit,
+ tokenPercent: planUsage.percent,
+ updatedAt: now)
+ }
+
+ static func parseUsageSnapshot(from data: Data, now: Date = Date()) throws -> MiMoUsageSnapshot {
+ let decoder = JSONDecoder()
+ let response = try decoder.decode(BalanceResponse.self, from: data)
+
+ guard response.code == 0 else {
+ let message = response.message?.trimmingCharacters(in: .whitespacesAndNewlines)
+ if response.code == 401 {
+ throw MiMoUsageError.loginRequired
+ }
+ if response.code == 403 {
+ throw MiMoUsageError.invalidCredentials
+ }
+ throw MiMoUsageError.parseFailed(message?.isEmpty == false ? message! : "code \(response.code)")
+ }
+
+ guard let data = response.data else {
+ throw MiMoUsageError.parseFailed("Missing balance payload")
+ }
+ guard let balance = Double(data.balance) else {
+ throw MiMoUsageError.parseFailed("Invalid balance value")
+ }
+
+ let currency = data.currency.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !currency.isEmpty else {
+ throw MiMoUsageError.parseFailed("Missing currency")
+ }
+
+ return MiMoUsageSnapshot(balance: balance, currency: currency, updatedAt: now)
+ }
+
+ static func parseTokenPlanDetail(from data: Data) throws -> (planCode: String?, periodEnd: Date?, expired: Bool) {
+ let decoder = JSONDecoder()
+ let response = try decoder.decode(TokenPlanDetailResponse.self, from: data)
+
+ guard response.code == 0, let payload = response.data else {
+ return (planCode: nil, periodEnd: nil, expired: false)
+ }
+
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+
+ let periodEnd: Date? = if let dateStr = payload.currentPeriodEnd {
+ formatter.date(from: dateStr)
+ } else {
+ nil
+ }
+
+ return (planCode: payload.planCode, periodEnd: periodEnd, expired: payload.expired)
+ }
+
+ static func parseTokenPlanUsage(from data: Data) throws -> (used: Int, limit: Int, percent: Double) {
+ let decoder = JSONDecoder()
+ let response = try decoder.decode(TokenPlanUsageResponse.self, from: data)
+
+ guard response.code == 0,
+ let monthUsage = response.data?.monthUsage,
+ let item = monthUsage.items.first
+ else {
+ return (used: 0, limit: 0, percent: 0)
+ }
+
+ return (used: item.used, limit: item.limit, percent: item.percent)
+ }
+
+ private struct BalanceResponse: Decodable {
+ let code: Int
+ let message: String?
+ let data: BalancePayload?
+ }
+
+ private struct BalancePayload: Decodable {
+ let balance: String
+ let currency: String
+ }
+
+ private struct TokenPlanDetailResponse: Decodable {
+ let code: Int
+ let message: String?
+ let data: TokenPlanDetailPayload?
+ }
+
+ private struct TokenPlanDetailPayload: Decodable {
+ let planCode: String?
+ let currentPeriodEnd: String?
+ let expired: Bool
+ }
+
+ private struct TokenPlanUsageResponse: Decodable {
+ let code: Int
+ let message: String?
+ let data: TokenPlanUsagePayload?
+ }
+
+ private struct TokenPlanUsagePayload: Decodable {
+ let monthUsage: MonthUsage?
+ }
+
+ private struct MonthUsage: Decodable {
+ let percent: Double
+ let items: [UsageItem]
+ }
+
+ private struct UsageItem: Decodable {
+ let name: String
+ let used: Int
+ let limit: Int
+ let percent: Double
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift
new file mode 100644
index 000000000..a55639a52
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift
@@ -0,0 +1,72 @@
+import Foundation
+
+public struct MiMoUsageSnapshot: Sendable {
+ public let balance: Double
+ public let currency: String
+ public let planCode: String?
+ public let planPeriodEnd: Date?
+ public let planExpired: Bool
+ public let tokenUsed: Int
+ public let tokenLimit: Int
+ public let tokenPercent: Double
+ public let updatedAt: Date
+
+ public init(
+ balance: Double,
+ currency: String,
+ planCode: String? = nil,
+ planPeriodEnd: Date? = nil,
+ planExpired: Bool = false,
+ tokenUsed: Int = 0,
+ tokenLimit: Int = 0,
+ tokenPercent: Double = 0,
+ updatedAt: Date)
+ {
+ self.balance = balance
+ self.currency = currency
+ self.planCode = planCode
+ self.planPeriodEnd = planPeriodEnd
+ self.planExpired = planExpired
+ self.tokenUsed = tokenUsed
+ self.tokenLimit = tokenLimit
+ self.tokenPercent = tokenPercent
+ self.updatedAt = updatedAt
+ }
+}
+
+extension MiMoUsageSnapshot {
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let trimmedCurrency = self.currency.trimmingCharacters(in: .whitespacesAndNewlines)
+ let balanceText = UsageFormatter.currencyString(self.balance, currencyCode: trimmedCurrency)
+
+ let primary: RateWindow? = {
+ guard self.tokenLimit > 0 else { return nil }
+ let usedPercent = max(0, min(100, self.tokenPercent * 100))
+ let resetDesc = "\(self.tokenUsed.formatted()) / \(self.tokenLimit.formatted()) Credits"
+ return RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: self.planPeriodEnd,
+ resetDescription: resetDesc)
+ }()
+
+ let planLabel: String? = {
+ guard let planCode = self.planCode else { return nil }
+ return planCode.capitalized
+ }()
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .mimo,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: planLabel ?? "Balance: \(balanceText)")
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift
index 5bf0a9c52..e758af40f 100644
--- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift
@@ -87,7 +87,17 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy {
throw MiniMaxAPISettingsError.missingToken
}
let region = context.settings?.minimax?.apiRegion ?? .global
- let usage = try await MiniMaxUsageFetcher.fetchUsage(apiToken: apiToken, region: region)
+ var usage = try await MiniMaxUsageFetcher.fetchUsage(apiToken: apiToken, region: region)
+ usage = MiniMaxUsageSnapshot(
+ planName: usage.planName,
+ availablePrompts: usage.availablePrompts,
+ currentPrompts: usage.currentPrompts,
+ remainingPrompts: usage.remainingPrompts,
+ windowMinutes: usage.windowMinutes,
+ usedPercent: usage.usedPercent,
+ resetsAt: usage.resetsAt,
+ updatedAt: usage.updatedAt,
+ apiKey: apiToken)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "api")
diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift
index 09ed671e2..359254c67 100644
--- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift
@@ -9,6 +9,7 @@ public struct MiniMaxUsageSnapshot: Sendable {
public let usedPercent: Double?
public let resetsAt: Date?
public let updatedAt: Date
+ public let apiKey: String?
public init(
planName: String?,
@@ -18,7 +19,8 @@ public struct MiniMaxUsageSnapshot: Sendable {
windowMinutes: Int?,
usedPercent: Double?,
resetsAt: Date?,
- updatedAt: Date)
+ updatedAt: Date,
+ apiKey: String? = nil)
{
self.planName = planName
self.availablePrompts = availablePrompts
@@ -28,6 +30,7 @@ public struct MiniMaxUsageSnapshot: Sendable {
self.usedPercent = usedPercent
self.resetsAt = resetsAt
self.updatedAt = updatedAt
+ self.apiKey = apiKey
}
}
@@ -43,9 +46,14 @@ extension MiniMaxUsageSnapshot {
let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines)
let loginMethod = (planName?.isEmpty ?? true) ? nil : planName
+ let maskedKey: String? = {
+ guard let key = self.apiKey, !key.isEmpty else { return nil }
+ if key.count <= 8 { return "****" }
+ return "\(key.prefix(6))...\(key.suffix(4))"
+ }()
let identity = ProviderIdentitySnapshot(
providerID: .minimax,
- accountEmail: nil,
+ accountEmail: maskedKey,
accountOrganization: nil,
loginMethod: loginMethod)
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index d7a3669d4..6d828f2c9 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -75,6 +75,13 @@ public enum ProviderDescriptorRegistry {
.synthetic: SyntheticProviderDescriptor.descriptor,
.openrouter: OpenRouterProviderDescriptor.descriptor,
.warp: WarpProviderDescriptor.descriptor,
+ .qwen: QwenProviderDescriptor.descriptor,
+ .doubao: DoubaoProviderDescriptor.descriptor,
+ .zenmux: ZenmuxProviderDescriptor.descriptor,
+ .aigocode: AigoCodeProviderDescriptor.descriptor,
+ .trae: TraeProviderDescriptor.descriptor,
+ .stepfun: StepFunProviderDescriptor.descriptor,
+ .mimo: MiMoProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index e9bf84f9e..34bc88649 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -14,6 +14,8 @@ public struct ProviderSettingsSnapshot: Sendable {
copilot: CopilotProviderSettings? = nil,
kilo: KiloProviderSettings? = nil,
kimi: KimiProviderSettings? = nil,
+ stepfun: StepFunProviderSettings? = nil,
+ mimo: MiMoProviderSettings? = nil,
augment: AugmentProviderSettings? = nil,
amp: AmpProviderSettings? = nil,
ollama: OllamaProviderSettings? = nil,
@@ -32,6 +34,8 @@ public struct ProviderSettingsSnapshot: Sendable {
copilot: copilot,
kilo: kilo,
kimi: kimi,
+ stepfun: stepfun,
+ mimo: mimo,
augment: augment,
amp: amp,
ollama: ollama,
@@ -153,6 +157,24 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct StepFunProviderSettings: Sendable {
+ public let cookieSource: ProviderCookieSource
+
+ public init(cookieSource: ProviderCookieSource) {
+ self.cookieSource = cookieSource
+ }
+ }
+
+ public struct MiMoProviderSettings: Sendable {
+ public let cookieSource: ProviderCookieSource
+ public let manualCookieHeader: String?
+
+ public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) {
+ self.cookieSource = cookieSource
+ self.manualCookieHeader = manualCookieHeader
+ }
+ }
+
public struct AugmentProviderSettings: Sendable {
public let cookieSource: ProviderCookieSource
public let manualCookieHeader: String?
@@ -203,6 +225,8 @@ public struct ProviderSettingsSnapshot: Sendable {
public let copilot: CopilotProviderSettings?
public let kilo: KiloProviderSettings?
public let kimi: KimiProviderSettings?
+ public let stepfun: StepFunProviderSettings?
+ public let mimo: MiMoProviderSettings?
public let augment: AugmentProviderSettings?
public let amp: AmpProviderSettings?
public let ollama: OllamaProviderSettings?
@@ -225,6 +249,8 @@ public struct ProviderSettingsSnapshot: Sendable {
copilot: CopilotProviderSettings?,
kilo: KiloProviderSettings?,
kimi: KimiProviderSettings?,
+ stepfun: StepFunProviderSettings?,
+ mimo: MiMoProviderSettings?,
augment: AugmentProviderSettings?,
amp: AmpProviderSettings?,
ollama: OllamaProviderSettings?,
@@ -242,6 +268,8 @@ public struct ProviderSettingsSnapshot: Sendable {
self.copilot = copilot
self.kilo = kilo
self.kimi = kimi
+ self.stepfun = stepfun
+ self.mimo = mimo
self.augment = augment
self.amp = amp
self.ollama = ollama
@@ -260,6 +288,8 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case copilot(ProviderSettingsSnapshot.CopilotProviderSettings)
case kilo(ProviderSettingsSnapshot.KiloProviderSettings)
case kimi(ProviderSettingsSnapshot.KimiProviderSettings)
+ case stepfun(ProviderSettingsSnapshot.StepFunProviderSettings)
+ case mimo(ProviderSettingsSnapshot.MiMoProviderSettings)
case augment(ProviderSettingsSnapshot.AugmentProviderSettings)
case amp(ProviderSettingsSnapshot.AmpProviderSettings)
case ollama(ProviderSettingsSnapshot.OllamaProviderSettings)
@@ -279,6 +309,8 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var copilot: ProviderSettingsSnapshot.CopilotProviderSettings?
public var kilo: ProviderSettingsSnapshot.KiloProviderSettings?
public var kimi: ProviderSettingsSnapshot.KimiProviderSettings?
+ public var stepfun: ProviderSettingsSnapshot.StepFunProviderSettings?
+ public var mimo: ProviderSettingsSnapshot.MiMoProviderSettings?
public var augment: ProviderSettingsSnapshot.AugmentProviderSettings?
public var amp: ProviderSettingsSnapshot.AmpProviderSettings?
public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings?
@@ -301,6 +333,8 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .copilot(value): self.copilot = value
case let .kilo(value): self.kilo = value
case let .kimi(value): self.kimi = value
+ case let .stepfun(value): self.stepfun = value
+ case let .mimo(value): self.mimo = value
case let .augment(value): self.augment = value
case let .amp(value): self.amp = value
case let .ollama(value): self.ollama = value
@@ -322,6 +356,8 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
copilot: self.copilot,
kilo: self.kilo,
kimi: self.kimi,
+ stepfun: self.stepfun,
+ mimo: self.mimo,
augment: self.augment,
amp: self.amp,
ollama: self.ollama,
diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
index ada9fac8d..dc550479a 100644
--- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
+++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
@@ -61,6 +61,30 @@ public enum ProviderTokenResolver {
self.openRouterResolution(environment: environment)?.token
}
+ public static func qwenToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.qwenResolution(environment: environment)?.token
+ }
+
+ public static func doubaoToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.doubaoResolution(environment: environment)?.token
+ }
+
+ public static func stepfunToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.stepfunResolution(environment: environment)?.token
+ }
+
+ public static func zenmuxToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.zenmuxResolution(environment: environment)?.token
+ }
+
+ public static func aigocodeToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.aigocodeResolution(environment: environment)?.token
+ }
+
+ public static func traeToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.traeResolution(environment: environment)?.token
+ }
+
public static func zaiResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
@@ -141,6 +165,42 @@ public enum ProviderTokenResolver {
self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment))
}
+ public static func qwenResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(QwenSettingsReader.apiKey(environment: environment))
+ }
+
+ public static func doubaoResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(DoubaoSettingsReader.apiKey(environment: environment))
+ }
+
+ public static func stepfunResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(StepFunSettingsReader.apiKey(environment: environment))
+ }
+
+ public static func zenmuxResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(ZenmuxSettingsReader.apiKey(environment: environment))
+ }
+
+ public static func aigocodeResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(AigoCodeSettingsReader.apiKey(environment: environment))
+ }
+
+ public static func traeResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(TraeSettingsReader.apiKey(environment: environment))
+ }
+
private static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index f48eefe43..bde72c000 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -25,6 +25,13 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case synthetic
case warp
case openrouter
+ case qwen
+ case doubao
+ case zenmux
+ case aigocode
+ case trae
+ case stepfun
+ case mimo
}
// swiftformat:enable sortDeclarations
@@ -52,6 +59,13 @@ public enum IconStyle: Sendable, CaseIterable {
case synthetic
case warp
case openrouter
+ case qwen
+ case doubao
+ case zenmux
+ case aigocode
+ case trae
+ case stepfun
+ case mimo
case combined
}
diff --git a/Sources/CodexBarCore/Providers/Qwen/QwenProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Qwen/QwenProviderDescriptor.swift
new file mode 100644
index 000000000..a390a6bc4
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Qwen/QwenProviderDescriptor.swift
@@ -0,0 +1,73 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum QwenProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .qwen,
+ metadata: ProviderMetadata(
+ id: .qwen,
+ displayName: "Qwen",
+ sessionLabel: "Requests",
+ weeklyLabel: "Monthly",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Qwen usage",
+ cliName: "qwen",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: nil,
+ dashboardURL: "https://bailian.console.aliyun.com/",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .qwen,
+ iconResourceName: "ProviderIcon-qwen",
+ color: ProviderColor(red: 106 / 255, green: 58 / 255, blue: 255 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Qwen cost summary is not available." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [QwenAPIFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "qwen",
+ aliases: ["tongyi", "dashscope", "lingma"],
+ versionDetector: nil))
+ }
+}
+
+struct QwenAPIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "qwen.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ Self.resolveToken(environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = Self.resolveToken(environment: context.env) else {
+ throw QwenUsageError.missingCredentials
+ }
+ let usage = try await QwenUsageFetcher.fetchUsage(apiKey: apiKey)
+ let accumulated = await LocalUsageTracker.shared.record(
+ provider: .qwen,
+ remaining: usage.remainingRequests,
+ limit: usage.limitRequests)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(accumulated: accumulated),
+ sourceLabel: "api")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveToken(environment: [String: String]) -> String? {
+ ProviderTokenResolver.qwenToken(environment: environment)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Qwen/QwenSettingsReader.swift b/Sources/CodexBarCore/Providers/Qwen/QwenSettingsReader.swift
new file mode 100644
index 000000000..4045cc2aa
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Qwen/QwenSettingsReader.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+public struct QwenSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKeys = [
+ "DASHSCOPE_API_KEY",
+ "QWEN_API_KEY",
+ ]
+
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ for key in self.apiKeyEnvironmentKeys {
+ guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !raw.isEmpty
+ else {
+ continue
+ }
+ let cleaned = Self.cleaned(raw)
+ if !cleaned.isEmpty {
+ return cleaned
+ }
+ }
+ return nil
+ }
+
+ private static func cleaned(_ raw: String) -> String {
+ var value = raw
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift b/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift
new file mode 100644
index 000000000..fe828084e
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift
@@ -0,0 +1,315 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct QwenUsageSnapshot: Sendable {
+ public let remainingRequests: Int
+ public let limitRequests: Int
+ public let resetTime: Date?
+ public let updatedAt: Date
+ public let apiKeyValid: Bool
+ public let totalTokens: Int?
+ public init(
+ remainingRequests: Int,
+ limitRequests: Int,
+ resetTime: Date?,
+ updatedAt: Date,
+ apiKeyValid: Bool = false,
+ totalTokens: Int? = nil)
+ {
+ self.remainingRequests = remainingRequests
+ self.limitRequests = limitRequests
+ self.resetTime = resetTime
+ self.updatedAt = updatedAt
+ self.apiKeyValid = apiKeyValid
+ self.totalTokens = totalTokens
+ }
+
+ public func toUsageSnapshot(
+ accumulated: LocalUsageTracker.AccumulatedUsage? = nil) -> UsageSnapshot
+ {
+ let usedPercent: Double
+ let resetDescription: String
+
+ if self.limitRequests > 0 {
+ let used = max(0, self.limitRequests - self.remainingRequests)
+ usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100))
+ resetDescription = "\(used)/\(self.limitRequests) requests"
+ } else if self.apiKeyValid {
+ usedPercent = 0
+ resetDescription = "Active — check dashboard for details"
+ } else {
+ usedPercent = 0
+ resetDescription = "No usage data"
+ }
+
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: self.resetTime,
+ resetDescription: resetDescription)
+
+ var secondary: RateWindow?
+ if let acc = accumulated, acc.weeklyLimit > 0 {
+ let weekPercent = min(100, max(0, Double(acc.weeklyRequests) / Double(acc.weeklyLimit) * 100))
+ secondary = RateWindow(
+ usedPercent: weekPercent,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: nil,
+ resetDescription: "\(acc.weeklyRequests)/\(acc.weeklyLimit) weekly")
+ }
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .qwen,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
+
+public enum QwenUsageError: LocalizedError, Sendable {
+ case missingCredentials
+ case networkError(String)
+ case apiError(Int, String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Missing Qwen API key (DASHSCOPE_API_KEY)."
+ case let .networkError(message):
+ "Qwen network error: \(message)"
+ case let .apiError(code, message):
+ "Qwen API error (\(code)): \(message)"
+ case let .parseFailed(message):
+ "Failed to parse Qwen response: \(message)"
+ }
+ }
+}
+
+public struct QwenUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.qwenUsage)
+
+ /// Coding plan keys (sk-sp-*) use the coding endpoint; regular keys use compatible mode.
+ private static func apiURL(for apiKey: String) -> URL {
+ if apiKey.hasPrefix("sk-sp-") {
+ URL(string: "https://coding.dashscope.aliyuncs.com/v1/chat/completions")!
+ } else {
+ URL(string: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions")!
+ }
+ }
+
+ /// Models to probe, ordered by likelihood. We try multiple models because
+ /// different key types / regions may not have access to every model.
+ private static let probeModels = [
+ "qwen3-coder-plus",
+ "qwen-turbo",
+ "qwen-plus",
+ ]
+
+ public static func fetchUsage(apiKey: String) async throws -> QwenUsageSnapshot {
+ guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw QwenUsageError.missingCredentials
+ }
+
+ var lastError: Error?
+ for model in self.probeModels {
+ do {
+ return try await self.probe(apiKey: apiKey, model: model)
+ } catch let error as QwenUsageError {
+ // Model not found / not entitled → try next model
+ if case let .apiError(code, _) = error, code == 404 || code == 403 {
+ Self.log.debug("Qwen probe model \(model) unavailable (\(code)), trying next")
+ lastError = error
+ continue
+ }
+ throw error
+ }
+ }
+ throw lastError ?? QwenUsageError.apiError(0, "All probe models failed")
+ }
+
+ private static func probe(apiKey: String, model: String) async throws -> QwenUsageSnapshot {
+ let url = self.apiURL(for: apiKey)
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 30
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+
+ let body: [String: Any] = [
+ "model": model,
+ "max_tokens": 1,
+ "messages": [
+ ["role": "user", "content": "hi"],
+ ] as [[String: Any]],
+ ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw QwenUsageError.networkError("Invalid response")
+ }
+
+ // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers.
+ guard httpResponse.statusCode == 200 || httpResponse.statusCode == 429 else {
+ let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data)
+ Self.log.error("Qwen API returned \(httpResponse.statusCode): \(summary)")
+ throw QwenUsageError.apiError(httpResponse.statusCode, summary)
+ }
+
+ let headers = httpResponse.allHeaderFields
+ let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests")
+ let limit = Self.intHeader(headers, "x-ratelimit-limit-requests")
+ let resetString = Self.stringHeader(headers, "x-ratelimit-reset-requests")
+
+ let resetTime: Date? = resetString.flatMap(Self.parseResetTime)
+
+ var totalTokens: Int?
+ if remaining == nil, limit == nil,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let usage = json["usage"] as? [String: Any]
+ {
+ totalTokens = usage["total_tokens"] as? Int
+ }
+
+ // 429 means the key is valid but rate-limited; treat it as valid so the UI
+ // shows "Active" instead of "No usage data" when headers are absent.
+ let keyValid = httpResponse.statusCode == 200 || httpResponse.statusCode == 429
+
+ let snapshot = QwenUsageSnapshot(
+ remainingRequests: remaining ?? 0,
+ limitRequests: limit ?? 0,
+ resetTime: resetTime,
+ updatedAt: Date(),
+ apiKeyValid: keyValid,
+ totalTokens: totalTokens)
+
+ Self.log.debug(
+ "Qwen usage parsed remaining=\(snapshot.remainingRequests) limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid)") // swiftlint:disable:this line_length
+
+ return snapshot
+ }
+
+ private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? {
+ if let value = headers[name] as? String { return value }
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.caseInsensitiveCompare(name) == .orderedSame,
+ let valStr = val as? String
+ {
+ return valStr
+ }
+ }
+ return nil
+ }
+
+ private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? {
+ if let value = headers[name] as? String, let int = Int(value) {
+ return int
+ }
+ if let value = headers[name.lowercased()] as? String, let int = Int(value) {
+ return int
+ }
+ // Case-insensitive search
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.lowercased() == name.lowercased(),
+ let valStr = val as? String,
+ let int = Int(valStr)
+ {
+ return int
+ }
+ }
+ return nil
+ }
+
+ /// Parse reset time from header value like "1d2h3m4s" or "30s" or ISO 8601.
+ private static func parseResetTime(_ value: String) -> Date? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return nil }
+
+ // Try ISO 8601 first
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = isoFormatter.date(from: trimmed) { return date }
+ let isoFallback = ISO8601DateFormatter()
+ isoFallback.formatOptions = [.withInternetDateTime]
+ if let date = isoFallback.date(from: trimmed) { return date }
+
+ // Try duration format like "1d2h3m4s" or "30s"
+ var seconds: TimeInterval = 0
+ let pattern = /(\d+)([dhms])/
+ for match in trimmed.matches(of: pattern) {
+ guard let num = Double(match.1) else { continue }
+ switch match.2 {
+ case "d": seconds += num * 86400
+ case "h": seconds += num * 3600
+ case "m": seconds += num * 60
+ case "s": seconds += num
+ default: break
+ }
+ }
+ if seconds > 0 {
+ return Date().addingTimeInterval(seconds)
+ }
+
+ // Try plain integer as seconds
+ if let secs = TimeInterval(trimmed) {
+ return Date().addingTimeInterval(secs)
+ }
+
+ return nil
+ }
+
+ private static func apiErrorSummary(statusCode: Int, data: Data) -> String {
+ guard let root = try? JSONSerialization.jsonObject(with: data),
+ let json = root as? [String: Any]
+ else {
+ if let text = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !text.isEmpty
+ {
+ return self.compactText(text)
+ }
+ return "Unexpected response body (\(data.count) bytes)."
+ }
+
+ if let message = json["message"] as? String {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ if let error = json["error"] as? [String: Any],
+ let message = error["message"] as? String
+ {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ return "HTTP \(statusCode) (\(data.count) bytes)."
+ }
+
+ private static func compactText(_ text: String, maxLength: Int = 200) -> String {
+ let collapsed = text
+ .components(separatedBy: .newlines)
+ .joined(separator: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if collapsed.count <= maxLength { return collapsed }
+ let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength)
+ return "\(collapsed[.. Void)? = nil) throws -> [SessionInfo]
+ {
+ var sessions: [SessionInfo] = []
+ let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
+ for browserSource in candidates {
+ do {
+ let perSource = try self.importSessions(from: browserSource, logger: logger)
+ sessions.append(contentsOf: perSource)
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ self.emit(
+ "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)",
+ logger: logger)
+ }
+ }
+
+ guard !sessions.isEmpty else {
+ throw StepFunCookieImportError.noCookies
+ }
+ return sessions
+ }
+
+ public static func importSessions(
+ from browserSource: Browser,
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let log: (String) -> Void = { msg in self.emit(msg, logger: logger) }
+ let sources = try Self.cookieClient.records(
+ matching: query,
+ in: browserSource,
+ logger: log)
+
+ var sessions: [SessionInfo] = []
+ let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id })
+ let sortedGroups = grouped.values.sorted { lhs, rhs in
+ self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs)
+ }
+
+ for group in sortedGroups where !group.isEmpty {
+ let label = self.mergedLabel(for: group)
+ let mergedRecords = self.mergeRecords(group)
+ guard !mergedRecords.isEmpty else { continue }
+ let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin)
+ guard !httpCookies.isEmpty else { continue }
+
+ // Only include sessions that have the Oasis-Token cookie
+ guard httpCookies.contains(where: { $0.name == "Oasis-Token" }) else {
+ continue
+ }
+
+ log("Found Oasis-Token cookie in \(label)")
+ sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: label))
+ }
+ return sessions
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger)
+ guard let first = sessions.first else {
+ throw StepFunCookieImportError.noCookies
+ }
+ return first
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ do {
+ return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty
+ } catch {
+ return false
+ }
+ }
+
+ private static func emit(_ message: String, logger: ((String) -> Void)?) {
+ logger?("[stepfun-cookie] \(message)")
+ self.log.debug(message)
+ }
+
+ private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String {
+ guard let base = sources.map(\.label).min() else {
+ return "Unknown"
+ }
+ if base.hasSuffix(" (Network)") {
+ return String(base.dropLast(" (Network)".count))
+ }
+ return base
+ }
+
+ private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] {
+ let sortedSources = sources.sorted { lhs, rhs in
+ self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind)
+ }
+ var mergedByKey: [String: BrowserCookieRecord] = [:]
+ for source in sortedSources {
+ for record in source.records {
+ let key = "\(record.name)|\(record.domain)|\(record.path)"
+ if let existing = mergedByKey[key] {
+ if self.shouldReplace(existing: existing, candidate: record) {
+ mergedByKey[key] = record
+ }
+ } else {
+ mergedByKey[key] = record
+ }
+ }
+ }
+ return Array(mergedByKey.values)
+ }
+
+ private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int {
+ switch kind {
+ case .network: 0
+ case .primary: 1
+ case .safari: 2
+ }
+ }
+
+ private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool {
+ switch (existing.expires, candidate.expires) {
+ case let (lhs?, rhs?):
+ rhs > lhs
+ case (nil, .some):
+ true
+ case (.some, nil):
+ false
+ case (nil, nil):
+ false
+ }
+ }
+}
+
+enum StepFunCookieImportError: LocalizedError {
+ case noCookies
+
+ var errorDescription: String? {
+ switch self {
+ case .noCookies:
+ "No StepFun session cookies found in browsers."
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunDashboardFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunDashboardFetcher.swift
new file mode 100644
index 000000000..3d79b86ed
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/StepFun/StepFunDashboardFetcher.swift
@@ -0,0 +1,200 @@
+#if os(macOS)
+import Foundation
+import WebKit
+
+/// Scrapes the StepFun plan-subscribe dashboard to extract Step Plan usage data.
+///
+/// StepFun's plan API (GetStepPlanStatus, QueryStepPlanRateLimit) requires browser-bound
+/// Oasis-Token that rejects non-browser HTTP clients ("embezzled" error). Using WKWebView
+/// ensures the TLS fingerprint matches a real browser, bypassing this restriction.
+@MainActor
+public struct StepFunDashboardFetcher {
+ public enum FetchError: LocalizedError {
+ case loginRequired
+ case noUsageData(body: String)
+ case timeout
+
+ public var errorDescription: String? {
+ switch self {
+ case .loginRequired:
+ "StepFun web access requires login. Open Settings → StepFun → Login in Browser."
+ case let .noUsageData(body):
+ "StepFun dashboard data not found. Body sample: \(body.prefix(200))"
+ case .timeout:
+ "StepFun dashboard loading timed out."
+ }
+ }
+ }
+
+ public struct DashboardSnapshot: Sendable {
+ public let planName: String?
+ public let planExpiry: String?
+ public let fiveHourLeftPercent: Double?
+ public let fiveHourResetTime: String?
+ public let weeklyLeftPercent: Double?
+ public let weeklyResetTime: String?
+ }
+
+ private static let log = CodexBarLog.logger(LogCategories.stepfunUsage)
+ private static let dashboardURL = URL(string: "https://platform.stepfun.com/plan-subscribe")!
+
+ public init() {}
+
+ public func fetchDashboard(
+ websiteDataStore: WKWebsiteDataStore = .default(),
+ timeout: TimeInterval = 30) async throws -> DashboardSnapshot
+ {
+ let deadline = Date().addingTimeInterval(max(1, timeout))
+
+ let config = WKWebViewConfiguration()
+ config.websiteDataStore = websiteDataStore
+ let webView = WKWebView(frame: CGRect(x: -9999, y: -9999, width: 1200, height: 900), configuration: config)
+ webView.customUserAgent =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
+
+ defer {
+ webView.stopLoading()
+ webView.loadHTMLString("", baseURL: nil)
+ }
+
+ _ = webView.load(URLRequest(url: Self.dashboardURL))
+ Self.log.debug("Loading StepFun dashboard…")
+
+ var lastBody = ""
+ while Date() < deadline {
+ try? await Task.sleep(for: .milliseconds(2000))
+
+ let scrape = try await self.scrape(webView: webView)
+
+ if scrape.isLoginPage {
+ Self.log.debug("Login page detected")
+ throw FetchError.loginRequired
+ }
+
+ lastBody = scrape.bodyText
+
+ if let snapshot = scrape.snapshot {
+ Self.log.debug(
+ "Dashboard parsed: plan=\(snapshot.planName ?? "nil") " +
+ "5h=\(snapshot.fiveHourLeftPercent ?? -1)% " +
+ "weekly=\(snapshot.weeklyLeftPercent ?? -1)%")
+ return snapshot
+ }
+ }
+
+ throw FetchError.noUsageData(body: lastBody)
+ }
+
+ // MARK: - JavaScript Scraping
+
+ private struct ScrapeResult {
+ let isLoginPage: Bool
+ let bodyText: String
+ let snapshot: DashboardSnapshot?
+ }
+
+ private func scrape(webView: WKWebView) async throws -> ScrapeResult {
+ let js = """
+ (() => {
+ const href = window.location.href;
+ const body = document.body ? document.body.innerText : '';
+
+ // Detect login/redirect page
+ const isLogin = href.includes('need_login_in=1') ||
+ (body.includes('登录') && !body.includes('订阅详情'));
+
+ if (isLogin) {
+ return JSON.stringify({ isLogin: true, body: body.substring(0, 500) });
+ }
+
+ // Extract plan name: "Plus Plan" or similar
+ const planMatch = body.match(/订阅的版本为(\\S+\\s*Plan)/);
+ const plan = planMatch ? planMatch[1] : null;
+
+ // Extract expiry date: "有效期截止至2026年04月22日"
+ const expiryMatch = body.match(/有效期截止至(\\d{4}年\\d{2}月\\d{2}日)/);
+ const expiry = expiryMatch ? expiryMatch[1] : null;
+
+ // Extract 5-hour usage: "剩余 100%" or "剩余 85%"
+ // Page structure: "5小时用量" followed by "剩余 XX%"
+ const fiveHourMatch = body.match(/5小时用量[\\s\\S]*?剩余\\s*(\\d+)%/);
+ const fiveHourPct = fiveHourMatch ? parseInt(fiveHourMatch[1]) : null;
+
+ // Extract 5-hour reset time
+ const fiveHourResetMatch = body.match(/5小时用量[\\s\\S]*?重置时间:\\s*([\\d-]+\\s+[\\d:]+)/);
+ const fiveHourReset = fiveHourResetMatch ? fiveHourResetMatch[1] : null;
+
+ // Extract weekly usage: "每周用量" followed by "剩余 XX%"
+ const weeklyMatch = body.match(/每周用量[\\s\\S]*?剩余\\s*(\\d+)%/);
+ const weeklyPct = weeklyMatch ? parseInt(weeklyMatch[1]) : null;
+
+ // Extract weekly reset time
+ const weeklyResetMatch = body.match(/每周用量[\\s\\S]*?重置时间:\\s*([\\d-]+\\s+[\\d:]+)/);
+ const weeklyReset = weeklyResetMatch ? weeklyResetMatch[1] : null;
+
+ const hasData = plan || fiveHourPct !== null || weeklyPct !== null;
+
+ return JSON.stringify({
+ isLogin: false,
+ body: body.substring(0, 1500),
+ plan: plan,
+ expiry: expiry,
+ fiveHourPct: fiveHourPct,
+ fiveHourReset: fiveHourReset,
+ weeklyPct: weeklyPct,
+ weeklyReset: weeklyReset,
+ hasData: hasData,
+ href: href
+ });
+ })();
+ """
+
+ guard let resultStr = try await webView.evaluateJavaScript(js) as? String,
+ let data = resultStr.data(using: .utf8),
+ let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else {
+ return ScrapeResult(isLoginPage: false, bodyText: "", snapshot: nil)
+ }
+
+ let isLogin = (dict["isLogin"] as? Bool) ?? false
+ let bodyText = (dict["body"] as? String) ?? ""
+
+ if isLogin {
+ return ScrapeResult(isLoginPage: true, bodyText: bodyText, snapshot: nil)
+ }
+
+ let hasData = (dict["hasData"] as? Bool) ?? false
+ guard hasData else {
+ return ScrapeResult(isLoginPage: false, bodyText: bodyText, snapshot: nil)
+ }
+
+ let snapshot = DashboardSnapshot(
+ planName: dict["plan"] as? String,
+ planExpiry: dict["expiry"] as? String,
+ fiveHourLeftPercent: (dict["fiveHourPct"] as? Int).map { Double($0) },
+ fiveHourResetTime: dict["fiveHourReset"] as? String,
+ weeklyLeftPercent: (dict["weeklyPct"] as? Int).map { Double($0) },
+ weeklyResetTime: dict["weeklyReset"] as? String)
+
+ return ScrapeResult(isLoginPage: false, bodyText: bodyText, snapshot: snapshot)
+ }
+
+ /// Bridge for calling from non-MainActor contexts (e.g. ProviderFetchStrategy).
+ public nonisolated static func fetchFromMainActor(
+ timeout: TimeInterval = 30) async throws -> DashboardSnapshot
+ {
+ try await withCheckedThrowingContinuation { continuation in
+ Task { @MainActor in
+ do {
+ let fetcher = StepFunDashboardFetcher()
+ let snapshot = try await fetcher.fetchDashboard(timeout: timeout)
+ continuation.resume(returning: snapshot)
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift
new file mode 100644
index 000000000..e6e96c870
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift
@@ -0,0 +1,107 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum StepFunProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .stepfun,
+ metadata: ProviderMetadata(
+ id: .stepfun,
+ displayName: "StepFun",
+ sessionLabel: "5h Rate",
+ weeklyLabel: "Weekly",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show StepFun usage",
+ cliName: "stepfun",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder,
+ dashboardURL: "https://platform.stepfun.com/plan-subscribe",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .stepfun,
+ iconResourceName: "ProviderIcon-stepfun",
+ color: ProviderColor(red: 99 / 255, green: 102 / 255, blue: 241 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "StepFun cost summary is not available." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [StepFunWebFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "stepfun",
+ aliases: ["step", "stepfun-ai"],
+ versionDetector: nil))
+ }
+}
+
+struct StepFunWebFetchStrategy: ProviderFetchStrategy {
+ let id: String = "stepfun.web"
+ let kind: ProviderFetchKind = .web
+ private static let log = CodexBarLog.logger(LogCategories.stepfunUsage)
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ // API key is always required for balance
+ guard Self.resolveAPIKey(environment: context.env) != nil else {
+ return false
+ }
+ return true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = Self.resolveAPIKey(environment: context.env) else {
+ throw StepFunUsageError.missingCredentials
+ }
+
+ // Try WKWebView dashboard scraping for plan/rate limit data (like AigoCode)
+ #if os(macOS)
+ var dashboardSnapshot: StepFunDashboardFetcher.DashboardSnapshot?
+ if context.settings?.stepfun?.cookieSource != .off {
+ do {
+ dashboardSnapshot = try await StepFunDashboardFetcher.fetchFromMainActor(timeout: 20)
+ Self.log.debug("Got StepFun plan data from WKWebView dashboard")
+ } catch {
+ Self.log.debug("StepFun dashboard fetch failed: \(error.localizedDescription)")
+ }
+ }
+ #endif
+
+ var dashData: StepFunUsageFetcher.DashboardData?
+ #if os(macOS)
+ if let ds = dashboardSnapshot {
+ dashData = StepFunUsageFetcher.DashboardData(
+ planName: ds.planName,
+ planExpiry: ds.planExpiry,
+ fiveHourLeftPercent: ds.fiveHourLeftPercent,
+ fiveHourResetTime: ds.fiveHourResetTime,
+ weeklyLeftPercent: ds.weeklyLeftPercent,
+ weeklyResetTime: ds.weeklyResetTime)
+ }
+ #endif
+
+ let snapshot = try await StepFunUsageFetcher.fetchUsage(
+ apiKey: apiKey, dashboardData: dashData)
+ #if os(macOS)
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: dashboardSnapshot != nil ? "web+api" : "api")
+ #else
+ return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "api")
+ #endif
+ }
+
+ func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool {
+ if case StepFunUsageError.missingCredentials = error { return false }
+ return true
+ }
+
+ private static func resolveAPIKey(environment: [String: String]) -> String? {
+ ProviderTokenResolver.stepfunToken(environment: environment)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift
new file mode 100644
index 000000000..7a8f0542f
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+public struct StepFunSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKeys = [
+ "STEPFUN_API_KEY",
+ "STEP_API_KEY",
+ ]
+
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ for key in self.apiKeyEnvironmentKeys {
+ guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !raw.isEmpty
+ else {
+ continue
+ }
+ let cleaned = Self.cleaned(raw)
+ if !cleaned.isEmpty {
+ return cleaned
+ }
+ }
+ return nil
+ }
+
+ private static func cleaned(_ raw: String) -> String {
+ var value = raw
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift
new file mode 100644
index 000000000..e2a3e447e
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift
@@ -0,0 +1,287 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct StepFunUsageSnapshot: Sendable {
+ // API balance (prepaid account)
+ public let balance: Double
+ public let cashBalance: Double
+ public let voucherBalance: Double
+ public let accountType: String
+
+ // Step Plan (coding subscription)
+ public let planName: String?
+ public let planExpiredAt: Date?
+ public let planAutoRenew: Bool
+ public let fiveHourLeftRate: Double?
+ public let fiveHourResetTime: Date?
+ public let weeklyLeftRate: Double?
+ public let weeklyResetTime: Date?
+
+ public let updatedAt: Date
+ public let apiKeyValid: Bool
+
+ public init(
+ balance: Double,
+ cashBalance: Double,
+ voucherBalance: Double,
+ accountType: String,
+ planName: String? = nil,
+ planExpiredAt: Date? = nil,
+ planAutoRenew: Bool = false,
+ fiveHourLeftRate: Double? = nil,
+ fiveHourResetTime: Date? = nil,
+ weeklyLeftRate: Double? = nil,
+ weeklyResetTime: Date? = nil,
+ updatedAt: Date,
+ apiKeyValid: Bool = true)
+ {
+ self.balance = balance
+ self.cashBalance = cashBalance
+ self.voucherBalance = voucherBalance
+ self.accountType = accountType
+ self.planName = planName
+ self.planExpiredAt = planExpiredAt
+ self.planAutoRenew = planAutoRenew
+ self.fiveHourLeftRate = fiveHourLeftRate
+ self.fiveHourResetTime = fiveHourResetTime
+ self.weeklyLeftRate = weeklyLeftRate
+ self.weeklyResetTime = weeklyResetTime
+ self.updatedAt = updatedAt
+ self.apiKeyValid = apiKeyValid
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ // Primary: 5-hour rate limit if available, otherwise balance
+ let primary: RateWindow
+ if let rate = self.fiveHourLeftRate {
+ let usedPercent = max(0, min(100, (1.0 - rate) * 100))
+ let pctStr = String(format: "%.0f%%", rate * 100)
+ primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: 5 * 60,
+ resetsAt: self.fiveHourResetTime,
+ resetDescription: "5h remaining: \(pctStr)")
+ } else {
+ // Balance display: show as "active" with balance info, low usedPercent
+ let balanceStr = String(format: "¥%.2f", self.balance)
+ let voucherStr = self.voucherBalance > 0
+ ? String(format: " (voucher: ¥%.2f)", self.voucherBalance) : ""
+ primary = RateWindow(
+ usedPercent: self.balance > 0 ? 0 : 100,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "Balance: \(balanceStr)\(voucherStr)")
+ }
+
+ // Secondary: weekly rate limit if available, otherwise nil
+ var secondary: RateWindow?
+ if let weekRate = self.weeklyLeftRate {
+ let weekUsed = max(0, min(100, (1.0 - weekRate) * 100))
+ let weekPctStr = String(format: "%.0f%%", weekRate * 100)
+ secondary = RateWindow(
+ usedPercent: weekUsed,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: self.weeklyResetTime,
+ resetDescription: "Weekly remaining: \(weekPctStr)")
+ }
+
+ // Tertiary: plan info + balance
+ var tertiary: RateWindow?
+ if let planName = self.planName {
+ var desc = "\(planName) Plan"
+ if let exp = self.planExpiredAt {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd"
+ desc += " → \(formatter.string(from: exp))"
+ }
+ let balanceStr = String(format: "¥%.2f", self.balance)
+ desc += " | Balance: \(balanceStr)"
+ tertiary = RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: self.planExpiredAt,
+ resetDescription: desc)
+ }
+
+ let org: String? = if let planName = self.planName {
+ "\(planName) Plan"
+ } else {
+ self.accountType == "prepaid" ? "Prepaid" : "Postpaid"
+ }
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .stepfun,
+ accountEmail: nil,
+ accountOrganization: org,
+ loginMethod: nil)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: tertiary,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
+
+public enum StepFunUsageError: LocalizedError, Sendable {
+ case missingCredentials
+ case networkError(String)
+ case apiError(Int, String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Missing StepFun API key (STEPFUN_API_KEY)."
+ case let .networkError(message):
+ "StepFun network error: \(message)"
+ case let .apiError(code, message):
+ "StepFun API error (\(code)): \(message)"
+ case let .parseFailed(message):
+ "Failed to parse StepFun response: \(message)"
+ }
+ }
+}
+
+public struct StepFunUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.stepfunUsage)
+ private static let accountsURL = URL(string: "https://api.stepfun.com/v1/accounts")!
+
+ public static func fetchUsage(
+ apiKey: String,
+ dashboardData: DashboardData? = nil) async throws -> StepFunUsageSnapshot
+ {
+ try await self._fetchUsage(apiKey: apiKey, dashboardSnapshot: dashboardData)
+ }
+
+ public struct DashboardData: Sendable {
+ public let planName: String?
+ public let planExpiry: String?
+ public let fiveHourLeftPercent: Double?
+ public let fiveHourResetTime: String?
+ public let weeklyLeftPercent: Double?
+ public let weeklyResetTime: String?
+ }
+
+ private static func _fetchUsage(
+ apiKey: String,
+ dashboardSnapshot: DashboardData?) async throws -> StepFunUsageSnapshot
+ {
+ guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw StepFunUsageError.missingCredentials
+ }
+
+ // 1. Fetch API balance (always works with API key)
+ let (balance, cashBalance, voucherBalance, accountType) = try await self.fetchAccountBalance(apiKey: apiKey)
+
+ // 2. Map dashboard snapshot to our model
+ var planName: String?
+ var planExpiredAt: Date?
+ let planAutoRenew = false
+ var fiveHourLeftRate: Double?
+ var fiveHourResetTime: Date?
+ var weeklyLeftRate: Double?
+ var weeklyResetTime: Date?
+
+ if let dash = dashboardSnapshot {
+ planName = dash.planName
+ if let expStr = dash.planExpiry {
+ // Parse "2026年04月22日"
+ let fmt = DateFormatter()
+ fmt.dateFormat = "yyyy年MM月dd日"
+ planExpiredAt = fmt.date(from: expStr)
+ }
+ if let pct = dash.fiveHourLeftPercent {
+ fiveHourLeftRate = pct / 100.0
+ }
+ if let pct = dash.weeklyLeftPercent {
+ weeklyLeftRate = pct / 100.0
+ }
+ if let resetStr = dash.fiveHourResetTime {
+ let fmt = DateFormatter()
+ fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ fiveHourResetTime = fmt.date(from: resetStr)
+ }
+ if let resetStr = dash.weeklyResetTime {
+ let fmt = DateFormatter()
+ fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ weeklyResetTime = fmt.date(from: resetStr)
+ }
+ }
+
+ Self.log.debug(
+ "StepFun balance=\(balance) plan=\(planName ?? "none") 5h=\(fiveHourLeftRate ?? -1) weekly=\(weeklyLeftRate ?? -1)") // swiftlint:disable:this line_length
+
+ return StepFunUsageSnapshot(
+ balance: balance,
+ cashBalance: cashBalance,
+ voucherBalance: voucherBalance,
+ accountType: accountType,
+ planName: planName,
+ planExpiredAt: planExpiredAt,
+ planAutoRenew: planAutoRenew,
+ fiveHourLeftRate: fiveHourLeftRate,
+ fiveHourResetTime: fiveHourResetTime,
+ weeklyLeftRate: weeklyLeftRate,
+ weeklyResetTime: weeklyResetTime,
+ updatedAt: Date())
+ }
+
+ // MARK: - API Balance
+
+ private static func fetchAccountBalance(apiKey: String) async throws -> (Double, Double, Double, String) {
+ var request = URLRequest(url: self.accountsURL)
+ request.httpMethod = "GET"
+ request.timeoutInterval = 30
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw StepFunUsageError.networkError("Invalid response")
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let summary = Self.errorSummary(data: data)
+ Self.log.error("StepFun accounts API returned \(httpResponse.statusCode): \(summary)")
+ if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
+ throw StepFunUsageError.apiError(httpResponse.statusCode, "Invalid API key")
+ }
+ throw StepFunUsageError.apiError(httpResponse.statusCode, summary)
+ }
+
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw StepFunUsageError.parseFailed("Invalid JSON response")
+ }
+
+ let balance = (json["balance"] as? Double) ?? 0
+ let cashBalance = (json["total_cash_balance"] as? Double) ?? 0
+ let voucherBalance = (json["total_voucher_balance"] as? Double) ?? 0
+ let accountType = (json["type"] as? String) ?? "prepaid"
+
+ return (balance, cashBalance, voucherBalance, accountType)
+ }
+
+ // MARK: - Helpers
+
+ private static func errorSummary(data: Data) -> String {
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let error = json["error"] as? [String: Any],
+ let message = error["message"] as? String
+ {
+ return message
+ }
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let message = json["message"] as? String
+ {
+ return message
+ }
+ return String(data: data, encoding: .utf8) ?? "Unknown error"
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Trae/TraeCookieImporter.swift b/Sources/CodexBarCore/Providers/Trae/TraeCookieImporter.swift
new file mode 100644
index 000000000..34260814a
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Trae/TraeCookieImporter.swift
@@ -0,0 +1,208 @@
+#if os(macOS)
+import Foundation
+import SweetCookieKit
+
+/// Imports Trae session cookies from browsers.
+///
+/// Trae uses ByteDance Passport for authentication, storing session
+/// cookies (`sessionid`, `sid_tt`, `passport_csrf_token`) on the `.trae.ai` domain.
+public enum TraeCookieImporter {
+ private static let log = CodexBarLog.logger(LogCategories.traeCookie)
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = [".trae.ai", "trae.ai", "www.trae.ai"]
+ private static let cookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.trae]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+ /// Key cookie names used for authentication.
+ private static let authCookieNames: Set = [
+ "sessionid",
+ "sid_tt",
+ ]
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public init(cookies: [HTTPCookie], sourceLabel: String) {
+ self.cookies = cookies
+ self.sourceLabel = sourceLabel
+ }
+
+ /// The session ID cookie value (primary auth token).
+ public var sessionId: String? {
+ self.cookies.first(where: { $0.name == "sessionid" })?.value
+ }
+
+ /// The `sid_tt` cookie value (secondary auth token).
+ public var sidTT: String? {
+ self.cookies.first(where: { $0.name == "sid_tt" })?.value
+ }
+
+ /// The CSRF token for passport requests.
+ public var csrfToken: String? {
+ self.cookies.first(where: { $0.name == "passport_csrf_token" })?.value
+ }
+
+ /// The Cloudide session header value.
+ public var cloudideSession: String? {
+ self.cookies.first(where: { $0.name == "X-Cloudide-Session" })?.value
+ }
+
+ /// Builds a Cookie header string from all cookies.
+ public var cookieHeader: String {
+ self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
+ }
+ }
+
+ public static func importSessions(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ var sessions: [SessionInfo] = []
+ let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
+ for browserSource in candidates {
+ do {
+ let perSource = try self.importSessions(from: browserSource, logger: logger)
+ sessions.append(contentsOf: perSource)
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ self.emit(
+ "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)",
+ logger: logger)
+ }
+ }
+
+ guard !sessions.isEmpty else {
+ throw TraeCookieImportError.noCookies
+ }
+ return sessions
+ }
+
+ public static func importSessions(
+ from browserSource: Browser,
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let log: (String) -> Void = { msg in self.emit(msg, logger: logger) }
+ let sources = try Self.cookieClient.records(
+ matching: query,
+ in: browserSource,
+ logger: log)
+
+ var sessions: [SessionInfo] = []
+ let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id })
+ let sortedGroups = grouped.values.sorted { lhs, rhs in
+ self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs)
+ }
+
+ for group in sortedGroups where !group.isEmpty {
+ let label = self.mergedLabel(for: group)
+ let mergedRecords = self.mergeRecords(group)
+ guard !mergedRecords.isEmpty else { continue }
+ let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin)
+ guard !httpCookies.isEmpty else { continue }
+
+ // Only include sessions that have at least one auth cookie
+ let hasAuth = httpCookies.contains(where: { self.authCookieNames.contains($0.name) })
+ guard hasAuth else { continue }
+
+ log("Found Trae session cookies in \(label)")
+ sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: label))
+ }
+ return sessions
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger)
+ guard let first = sessions.first else {
+ throw TraeCookieImportError.noCookies
+ }
+ return first
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ do {
+ return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty
+ } catch {
+ return false
+ }
+ }
+
+ private static func emit(_ message: String, logger: ((String) -> Void)?) {
+ logger?("[trae-cookie] \(message)")
+ self.log.debug(message)
+ }
+
+ private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String {
+ guard let base = sources.map(\.label).min() else {
+ return "Unknown"
+ }
+ if base.hasSuffix(" (Network)") {
+ return String(base.dropLast(" (Network)".count))
+ }
+ return base
+ }
+
+ private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] {
+ let sortedSources = sources.sorted { lhs, rhs in
+ self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind)
+ }
+ var mergedByKey: [String: BrowserCookieRecord] = [:]
+ for source in sortedSources {
+ for record in source.records {
+ let key = self.recordKey(record)
+ if let existing = mergedByKey[key] {
+ if self.shouldReplace(existing: existing, candidate: record) {
+ mergedByKey[key] = record
+ }
+ } else {
+ mergedByKey[key] = record
+ }
+ }
+ }
+ return Array(mergedByKey.values)
+ }
+
+ private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int {
+ switch kind {
+ case .network: 0
+ case .primary: 1
+ case .safari: 2
+ }
+ }
+
+ private static func recordKey(_ record: BrowserCookieRecord) -> String {
+ "\(record.name)|\(record.domain)|\(record.path)"
+ }
+
+ private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool {
+ switch (existing.expires, candidate.expires) {
+ case let (lhs?, rhs?):
+ rhs > lhs
+ case (nil, .some):
+ true
+ case (.some, nil):
+ false
+ case (nil, nil):
+ false
+ }
+ }
+}
+
+enum TraeCookieImportError: LocalizedError {
+ case noCookies
+
+ var errorDescription: String? {
+ switch self {
+ case .noCookies:
+ "No Trae session cookies found in browsers."
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Trae/TraeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Trae/TraeProviderDescriptor.swift
new file mode 100644
index 000000000..3ccd3f65d
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Trae/TraeProviderDescriptor.swift
@@ -0,0 +1,114 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum TraeProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .trae,
+ metadata: ProviderMetadata(
+ id: .trae,
+ displayName: "Trae",
+ sessionLabel: "Usage",
+ weeklyLabel: "Quota",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Trae usage",
+ cliName: "trae",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: nil,
+ dashboardURL: "https://www.trae.ai/account-setting#usage",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .trae,
+ iconResourceName: "ProviderIcon-trae",
+ color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Trae cost summary is not available." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in
+ [TraeWebFetchStrategy(), TraeLocalFetchStrategy()]
+ })),
+ cli: ProviderCLIConfig(
+ name: "trae",
+ aliases: [],
+ versionDetector: nil))
+ }
+}
+
+struct TraeLocalFetchStrategy: ProviderFetchStrategy {
+ let id: String = "trae.local"
+ let kind: ProviderFetchKind = .localProbe
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ FileManager.default.fileExists(atPath: "/Applications/Trae.app")
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let status = try await TraeStatusProbe.probe()
+ return self.makeResult(
+ usage: status.toUsageSnapshot(),
+ sourceLabel: "local")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+}
+
+#if os(macOS)
+struct TraeWebFetchStrategy: ProviderFetchStrategy {
+ let id: String = "trae.web"
+ let kind: ProviderFetchKind = .web
+ private static let log = CodexBarLog.logger(LogCategories.traeWeb)
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ if context.sourceMode == .web { return true }
+ if context.sourceMode == .auto {
+ return TraeCookieImporter.hasSession()
+ }
+ return false
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let cookieSession = try TraeCookieImporter.importSession()
+ Self.log.debug("Found Trae session in \(cookieSession.sourceLabel)")
+
+ let session = TraeSessionInfo(from: cookieSession)
+ let snapshot = try await TraeUsageFetcher.fetchUsage(session: session)
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: "web (\(cookieSession.sourceLabel))")
+ }
+
+ func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool {
+ if context.sourceMode == .web { return false }
+ // In auto mode, fall back to local probe on cookie/API errors
+ return true
+ }
+}
+#else
+struct TraeWebFetchStrategy: ProviderFetchStrategy {
+ let id: String = "trae.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ false
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ throw TraeAPIError.networkError("Web strategy not available on this platform")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ true
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Trae/TraeSettingsReader.swift b/Sources/CodexBarCore/Providers/Trae/TraeSettingsReader.swift
new file mode 100644
index 000000000..398076007
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Trae/TraeSettingsReader.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+public struct TraeSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKeys = [
+ "TRAE_API_KEY",
+ ]
+
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ for key in self.apiKeyEnvironmentKeys {
+ guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !raw.isEmpty
+ else {
+ continue
+ }
+ let cleaned = Self.cleaned(raw)
+ if !cleaned.isEmpty {
+ return cleaned
+ }
+ }
+ return nil
+ }
+
+ private static func cleaned(_ raw: String) -> String {
+ var value = raw
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Trae/TraeStatusProbe.swift b/Sources/CodexBarCore/Providers/Trae/TraeStatusProbe.swift
new file mode 100644
index 000000000..85275fd56
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Trae/TraeStatusProbe.swift
@@ -0,0 +1,104 @@
+import Foundation
+
+public struct TraeStatusSnapshot: Sendable {
+ public let isRunning: Bool
+ public let version: String?
+ public let updatedAt: Date
+
+ public init(isRunning: Bool, version: String?, updatedAt: Date) {
+ self.isRunning = isRunning
+ self.version = version
+ self.updatedAt = updatedAt
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let usedPercent: Double = self.isRunning ? 0 : 100
+ let resetDescription: String = self.isRunning
+ ? "Active — free tier"
+ : "Not running"
+
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: resetDescription)
+
+ let versionLabel = self.version.map { "v\($0)" }
+ let identity = ProviderIdentitySnapshot(
+ providerID: .trae,
+ accountEmail: versionLabel,
+ accountOrganization: nil,
+ loginMethod: self.isRunning ? "Free" : nil)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
+
+public enum TraeStatusProbeError: LocalizedError, Sendable {
+ case notInstalled
+ case notRunning
+
+ public var errorDescription: String? {
+ switch self {
+ case .notInstalled:
+ "Trae is not installed."
+ case .notRunning:
+ "Trae is not running."
+ }
+ }
+}
+
+public struct TraeStatusProbe: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.traeUsage)
+
+ public static func probe() async throws -> TraeStatusSnapshot {
+ let appPath = "/Applications/Trae.app"
+ let fm = FileManager.default
+ guard fm.fileExists(atPath: appPath) else {
+ throw TraeStatusProbeError.notInstalled
+ }
+
+ let isRunning = Self.isTraeRunning()
+ let version = Self.traeVersion(appPath: appPath)
+
+ Self.log.debug("Trae probe: running=\(isRunning) version=\(version ?? "unknown")")
+
+ return TraeStatusSnapshot(
+ isRunning: isRunning,
+ version: version,
+ updatedAt: Date())
+ }
+
+ private static func isTraeRunning() -> Bool {
+ let pipe = Pipe()
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
+ process.arguments = ["-f", "Trae.app"]
+ process.standardOutput = pipe
+ process.standardError = FileHandle.nullDevice
+ do {
+ try process.run()
+ process.waitUntilExit()
+ return process.terminationStatus == 0
+ } catch {
+ return false
+ }
+ }
+
+ private static func traeVersion(appPath: String) -> String? {
+ let plistPath = "\(appPath)/Contents/Info.plist"
+ guard let plistData = FileManager.default.contents(atPath: plistPath),
+ let plist = try? PropertyListSerialization.propertyList(
+ from: plistData, options: [], format: nil) as? [String: Any]
+ else {
+ return nil
+ }
+ return plist["CFBundleShortVersionString"] as? String
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Trae/TraeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Trae/TraeUsageFetcher.swift
new file mode 100644
index 000000000..9a915b081
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Trae/TraeUsageFetcher.swift
@@ -0,0 +1,407 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct TraeUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.traeWeb)
+ /// Global endpoint — routes to the correct region automatically.
+ /// Do NOT use ug-normal.us.trae.ai; it rejects non-US sessions.
+ private static let globalBase = "https://ug-normal.trae.ai"
+
+ private static func apiURL(_ base: String, path: String) -> URL {
+ URL(string: "\(base)/\(path)")!
+ }
+
+ private static func apiURL(_ base: URL, path: String) -> URL {
+ URL(string: "\(base.absoluteString)/\(path)")!
+ }
+
+ public static func fetchUsage(session: TraeSessionInfo, now: Date = Date()) async throws -> TraeUsageSnapshot {
+ // Step 1: Check login status and get region info
+ let loginResult = try await self.checkLogin(session: session)
+ guard loginResult.isLogin else {
+ throw TraeAPIError.invalidSession
+ }
+
+ Self.log.debug("Trae login valid: userID=\(loginResult.userID ?? "?") region=\(loginResult.region ?? "?")")
+
+ // Determine the regional host for subsequent API calls
+ let regionalBase: URL = if let host = loginResult.host, let url = URL(string: host) {
+ url
+ } else {
+ URL(string: self.globalBase)!
+ }
+
+ // Step 2: Fetch user profile and usage stats in parallel
+ async let profileResult = self.getUserInfo(base: regionalBase, session: session)
+ async let statsResult = self.getUserStats(base: regionalBase, session: session)
+
+ let profile = try await profileResult
+ let stats = try? await statsResult // stats failure is non-fatal
+
+ return TraeUsageSnapshot(
+ checkLogin: loginResult, profile: profile, stats: stats, updatedAt: now)
+ }
+
+ // MARK: - CheckLogin
+
+ private static func checkLogin(session: TraeSessionInfo) async throws -> TraeCheckLoginResult {
+ let url = self.apiURL(self.globalBase, path: "cloudide/api/v3/trae/CheckLogin")
+ var request = self.makeRequest(url: url, session: session)
+ request.httpBody = "{}".data(using: .utf8) // swiftlint:disable:this non_optional_string_data_conversion
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw TraeAPIError.networkError("Invalid response")
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ if httpResponse.statusCode == 401 {
+ throw TraeAPIError.invalidSession
+ }
+ throw TraeAPIError.apiError("CheckLogin HTTP \(httpResponse.statusCode)")
+ }
+
+ let volcResponse = try JSONDecoder().decode(TraeVolcResponse.self, from: data)
+ if let error = volcResponse.responseMetadata.error {
+ throw TraeAPIError.apiError("CheckLogin: \(error.message ?? error.code)")
+ }
+ guard let result = volcResponse.result else {
+ throw TraeAPIError.parseFailed("CheckLogin returned no Result")
+ }
+ return result
+ }
+
+ // MARK: - GetUserInfo (profile data)
+
+ private static func getUserInfo(
+ base: URL, session: TraeSessionInfo) async throws -> TraeProfileResult
+ {
+ let url = self.apiURL(base, path: "cloudide/api/v3/trae/GetUserInfo")
+ var request = self.makeRequest(url: url, session: session)
+ request.httpBody = "{}".data(using: .utf8) // swiftlint:disable:this non_optional_string_data_conversion
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ return TraeProfileResult()
+ }
+
+ do {
+ let volcResponse = try JSONDecoder().decode(
+ TraeVolcResponse.self, from: data)
+ if volcResponse.responseMetadata.error != nil { return TraeProfileResult() }
+ return volcResponse.result ?? TraeProfileResult()
+ } catch {
+ Self.log.warning("GetUserInfo decode failed: \(error)")
+ return TraeProfileResult()
+ }
+ }
+
+ // MARK: - GetUserStasticData (usage statistics)
+
+ private static func getUserStats(
+ base: URL, session: TraeSessionInfo) async throws -> TraeStatsResult
+ {
+ let url = self.apiURL(base, path: "cloudide/api/v3/trae/GetUserStasticData")
+ var request = self.makeRequest(url: url, session: session)
+
+ // API requires LocalTime (ISO 8601 with offset) and Offset (timezone minutes)
+ let now = Date()
+ let tz = TimeZone.current
+ let offsetMinutes = tz.secondsFromGMT(for: now) / 60
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ formatter.timeZone = tz
+ let localTime = formatter.string(from: now)
+
+ let body: [String: Any] = ["LocalTime": localTime, "Offset": offsetMinutes]
+ request.httpBody = try? JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ throw TraeAPIError.apiError("GetUserStasticData HTTP error")
+ }
+
+ let volcResponse = try JSONDecoder().decode(
+ TraeVolcResponse.self, from: data)
+ if let error = volcResponse.responseMetadata.error {
+ throw TraeAPIError.apiError("Stats: \(error.message ?? error.code)")
+ }
+ guard let result = volcResponse.result else {
+ throw TraeAPIError.parseFailed("GetUserStasticData returned no Result")
+ }
+ return result
+ }
+
+ // MARK: - Request Builder
+
+ private static func makeRequest(url: URL, session: TraeSessionInfo) -> URLRequest {
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue(session.cookieHeader, forHTTPHeaderField: "Cookie")
+ request.setValue("https://www.trae.ai", forHTTPHeaderField: "Origin")
+ request.setValue("https://www.trae.ai/account-setting", forHTTPHeaderField: "Referer")
+ let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
+ request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+
+ if let csrfToken = session.csrfToken {
+ request.setValue(csrfToken, forHTTPHeaderField: "x-csrf-token")
+ }
+ if let cloudideSession = session.cloudideSession {
+ request.setValue(cloudideSession, forHTTPHeaderField: "X-Cloudide-Session")
+ }
+ return request
+ }
+}
+
+// MARK: - Session Info (abstraction over cookie source)
+
+public struct TraeSessionInfo: Sendable {
+ public let cookieHeader: String
+ public let csrfToken: String?
+ public let cloudideSession: String?
+ public let sourceLabel: String
+
+ public init(cookieHeader: String, csrfToken: String?, cloudideSession: String?, sourceLabel: String) {
+ self.cookieHeader = cookieHeader
+ self.csrfToken = csrfToken
+ self.cloudideSession = cloudideSession
+ self.sourceLabel = sourceLabel
+ }
+
+ #if os(macOS)
+ public init(from cookieSession: TraeCookieImporter.SessionInfo) {
+ self.cookieHeader = cookieSession.cookieHeader
+ self.csrfToken = cookieSession.csrfToken
+ self.cloudideSession = cookieSession.cloudideSession
+ self.sourceLabel = cookieSession.sourceLabel
+ }
+ #endif
+}
+
+// MARK: - ByteDance Volc API Response Format
+
+/// Generic ByteDance Volc Engine API response wrapper.
+struct TraeVolcResponse: Codable, Sendable {
+ let responseMetadata: TraeVolcResponseMetadata
+ let result: T?
+
+ enum CodingKeys: String, CodingKey {
+ case responseMetadata = "ResponseMetadata"
+ case result = "Result"
+ }
+}
+
+struct TraeVolcResponseMetadata: Codable, Sendable {
+ let requestId: String?
+ let action: String?
+ let version: String?
+ let service: String?
+ let region: String?
+ let error: TraeVolcError?
+
+ enum CodingKeys: String, CodingKey {
+ case requestId = "RequestId"
+ case action = "Action"
+ case version = "Version"
+ case service = "Service"
+ case region = "Region"
+ case error = "Error"
+ }
+}
+
+struct TraeVolcError: Codable, Sendable {
+ let code: String
+ let standardCode: String?
+ let message: String?
+
+ enum CodingKeys: String, CodingKey {
+ case code = "Code"
+ case standardCode = "StandardCode"
+ case message = "Message"
+ }
+}
+
+// MARK: - CheckLogin Result
+
+struct TraeCheckLoginResult: Codable, Sendable {
+ let isLogin: Bool
+ let expiredAt: Int?
+ let region: String?
+ let host: String?
+ let userID: String?
+ let aiRegion: String?
+ let aiHost: String?
+ let aiPayHost: String?
+ let nickNameEditStatus: String?
+ let passwordChanged: Bool?
+
+ init(
+ isLogin: Bool = false,
+ expiredAt: Int? = nil,
+ region: String? = nil,
+ host: String? = nil,
+ userID: String? = nil,
+ aiRegion: String? = nil,
+ aiHost: String? = nil,
+ aiPayHost: String? = nil,
+ nickNameEditStatus: String? = nil,
+ passwordChanged: Bool? = nil)
+ {
+ self.isLogin = isLogin
+ self.expiredAt = expiredAt
+ self.region = region
+ self.host = host
+ self.userID = userID
+ self.aiRegion = aiRegion
+ self.aiHost = aiHost
+ self.aiPayHost = aiPayHost
+ self.nickNameEditStatus = nickNameEditStatus
+ self.passwordChanged = passwordChanged
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case isLogin = "IsLogin"
+ case expiredAt = "ExpiredAt"
+ case region = "Region"
+ case host = "Host"
+ case userID = "UserID"
+ case aiRegion = "AIRegion"
+ case aiHost = "AIHost"
+ case aiPayHost = "AIPayHost"
+ case nickNameEditStatus = "NickNameEditStatus"
+ case passwordChanged = "PasswordChanged"
+ }
+}
+
+// MARK: - GetUserInfo Result (profile data)
+
+struct TraeProfileResult: Codable, Sendable {
+ let screenName: String?
+ let userID: String?
+ let avatarURL: String?
+ let region: String?
+ let aiRegion: String?
+ let registerTime: String?
+ let lastLoginTime: String?
+ let lastLoginType: String?
+
+ init(
+ screenName: String? = nil,
+ userID: String? = nil,
+ avatarURL: String? = nil,
+ region: String? = nil,
+ aiRegion: String? = nil,
+ registerTime: String? = nil,
+ lastLoginTime: String? = nil,
+ lastLoginType: String? = nil)
+ {
+ self.screenName = screenName
+ self.userID = userID
+ self.avatarURL = avatarURL
+ self.region = region
+ self.aiRegion = aiRegion
+ self.registerTime = registerTime
+ self.lastLoginTime = lastLoginTime
+ self.lastLoginType = lastLoginType
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case screenName = "ScreenName"
+ case userID = "UserID"
+ case avatarURL = "AvatarUrl"
+ case region = "Region"
+ case aiRegion = "AIRegion"
+ case registerTime = "RegisterTime"
+ case lastLoginTime = "LastLoginTime"
+ case lastLoginType = "LastLoginType"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try? decoder.container(keyedBy: CodingKeys.self)
+ self.screenName = try? container?.decodeIfPresent(String.self, forKey: .screenName)
+ self.userID = try? container?.decodeIfPresent(String.self, forKey: .userID)
+ self.avatarURL = try? container?.decodeIfPresent(String.self, forKey: .avatarURL)
+ self.region = try? container?.decodeIfPresent(String.self, forKey: .region)
+ self.aiRegion = try? container?.decodeIfPresent(String.self, forKey: .aiRegion)
+ self.registerTime = try? container?.decodeIfPresent(String.self, forKey: .registerTime)
+ self.lastLoginTime = try? container?.decodeIfPresent(String.self, forKey: .lastLoginTime)
+ self.lastLoginType = try? container?.decodeIfPresent(String.self, forKey: .lastLoginType)
+ }
+}
+
+// MARK: - GetUserStasticData Result (usage statistics)
+
+struct TraeStatsResult: Codable, Sendable {
+ let userID: String?
+ let registerDays: Int?
+ /// AI interaction counts per day (key: "yyyyMMdd", value: count)
+ let aiCnt365d: [String: Int]?
+ let codeAiAcceptCnt7d: Int?
+ /// Accepted AI suggestions by language (key: language, value: count)
+ let codeAiAcceptDiffLanguageCnt7d: [String: Int]?
+ let codeCompCnt7d: Int?
+ /// Completions by agent (key: agent name, value: count)
+ let codeCompDiffAgentCnt7d: [String: Int]?
+ /// Completions by model (key: model name, value: count)
+ let codeCompDiffModelCnt7d: [String: Int]?
+ let dataDate: String?
+ let isIde: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case userID = "UserID"
+ case registerDays = "RegisterDays"
+ case aiCnt365d = "AiCnt365d"
+ case codeAiAcceptCnt7d = "CodeAiAcceptCnt7d"
+ case codeAiAcceptDiffLanguageCnt7d = "CodeAiAcceptDiffLanguageCnt7d"
+ case codeCompCnt7d = "CodeCompCnt7d"
+ case codeCompDiffAgentCnt7d = "CodeCompDiffAgentCnt7d"
+ case codeCompDiffModelCnt7d = "CodeCompDiffModelCnt7d"
+ case dataDate = "DataDate"
+ case isIde = "IsIde"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try? decoder.container(keyedBy: CodingKeys.self)
+ self.userID = try? container?.decodeIfPresent(String.self, forKey: .userID)
+ self.registerDays = try? container?.decodeIfPresent(Int.self, forKey: .registerDays)
+ self.aiCnt365d = try? container?.decodeIfPresent([String: Int].self, forKey: .aiCnt365d)
+ self.codeAiAcceptCnt7d = try? container?.decodeIfPresent(Int.self, forKey: .codeAiAcceptCnt7d)
+ self.codeAiAcceptDiffLanguageCnt7d = try? container?.decodeIfPresent(
+ [String: Int].self, forKey: .codeAiAcceptDiffLanguageCnt7d)
+ self.codeCompCnt7d = try? container?.decodeIfPresent(Int.self, forKey: .codeCompCnt7d)
+ self.codeCompDiffAgentCnt7d = try? container?.decodeIfPresent(
+ [String: Int].self, forKey: .codeCompDiffAgentCnt7d)
+ self.codeCompDiffModelCnt7d = try? container?.decodeIfPresent(
+ [String: Int].self, forKey: .codeCompDiffModelCnt7d)
+ self.dataDate = try? container?.decodeIfPresent(String.self, forKey: .dataDate)
+ self.isIde = try? container?.decodeIfPresent(Bool.self, forKey: .isIde)
+ }
+}
+
+// MARK: - Errors
+
+public enum TraeAPIError: LocalizedError, Sendable {
+ case invalidSession
+ case networkError(String)
+ case parseFailed(String)
+ case apiError(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidSession:
+ "Trae session expired. Please log in to trae.ai in your browser."
+ case let .networkError(msg):
+ "Trae network error: \(msg)"
+ case let .parseFailed(msg):
+ "Trae response parse failed: \(msg)"
+ case let .apiError(msg):
+ "Trae API error: \(msg)"
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Trae/TraeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Trae/TraeUsageSnapshot.swift
new file mode 100644
index 000000000..3bd42f858
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Trae/TraeUsageSnapshot.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+public struct TraeUsageSnapshot: Sendable {
+ let checkLogin: TraeCheckLoginResult
+ let profile: TraeProfileResult
+ let stats: TraeStatsResult?
+ public let updatedAt: Date
+}
+
+extension TraeUsageSnapshot {
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let primary: RateWindow
+
+ if let stats {
+ // Sum 7-day AI interaction counts
+ let total7d = (stats.codeAiAcceptCnt7d ?? 0) + (stats.codeCompCnt7d ?? 0)
+
+ // Build model breakdown string
+ let modelBreakdown = stats.codeCompDiffModelCnt7d?
+ .sorted { $0.value > $1.value }
+ .map { "\($0.key): \($0.value)" }
+ .joined(separator: ", ")
+
+ let description = if let modelBreakdown, !modelBreakdown.isEmpty {
+ "\(total7d) AI actions (7d) — \(modelBreakdown)"
+ } else {
+ "\(total7d) AI actions (7d)"
+ }
+
+ // Trae has no hard usage cap, so show activity level instead of percent
+ primary = RateWindow(
+ usedPercent: 0,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: nil,
+ resetDescription: description)
+ } else {
+ primary = RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "Active — logged in")
+ }
+
+ let accountName = self.profile.screenName
+ ?? self.checkLogin.userID
+ let regionInfo = self.checkLogin.region ?? self.profile.aiRegion
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .trae,
+ accountEmail: accountName,
+ accountOrganization: regionInfo,
+ loginMethod: self.profile.lastLoginType ?? "Web")
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift
index 1592a6181..0713291d6 100644
--- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift
+++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift
@@ -132,12 +132,20 @@ public struct ZaiUsageSnapshot: Sendable {
public let timeLimit: ZaiLimitEntry?
public let planName: String?
public let updatedAt: Date
+ public let apiKey: String?
- public init(tokenLimit: ZaiLimitEntry?, timeLimit: ZaiLimitEntry?, planName: String?, updatedAt: Date) {
+ public init(
+ tokenLimit: ZaiLimitEntry?,
+ timeLimit: ZaiLimitEntry?,
+ planName: String?,
+ updatedAt: Date,
+ apiKey: String? = nil)
+ {
self.tokenLimit = tokenLimit
self.timeLimit = timeLimit
self.planName = planName
self.updatedAt = updatedAt
+ self.apiKey = apiKey
}
/// Returns true if this snapshot contains valid z.ai data
@@ -160,9 +168,14 @@ extension ZaiUsageSnapshot {
let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines)
let loginMethod = (planName?.isEmpty ?? true) ? nil : planName
+ let maskedKey: String? = {
+ guard let key = self.apiKey, !key.isEmpty else { return nil }
+ if key.count <= 8 { return "****" }
+ return "\(key.prefix(6))...\(key.suffix(4))"
+ }()
let identity = ProviderIdentitySnapshot(
providerID: .zai,
- accountEmail: nil,
+ accountEmail: maskedKey,
accountOrganization: nil,
loginMethod: loginMethod)
return UsageSnapshot(
@@ -329,7 +342,14 @@ public struct ZaiUsageFetcher: Sendable {
}
do {
- return try Self.parseUsageSnapshot(from: data)
+ var snapshot = try Self.parseUsageSnapshot(from: data)
+ snapshot = ZaiUsageSnapshot(
+ tokenLimit: snapshot.tokenLimit,
+ timeLimit: snapshot.timeLimit,
+ planName: snapshot.planName,
+ updatedAt: snapshot.updatedAt,
+ apiKey: apiKey)
+ return snapshot
} catch let error as DecodingError {
Self.log.error("z.ai JSON decoding error: \(error.localizedDescription)")
throw ZaiUsageError.parseFailed(error.localizedDescription)
diff --git a/Sources/CodexBarCore/Providers/Zenmux/ZenmuxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zenmux/ZenmuxProviderDescriptor.swift
new file mode 100644
index 000000000..4f6158f81
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Zenmux/ZenmuxProviderDescriptor.swift
@@ -0,0 +1,69 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum ZenmuxProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .zenmux,
+ metadata: ProviderMetadata(
+ id: .zenmux,
+ displayName: "Zenmux",
+ sessionLabel: "Requests",
+ weeklyLabel: "Rate limit",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Zenmux usage",
+ cliName: "zenmux",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: nil,
+ dashboardURL: "https://zenmux.ai/platform/subscription",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .zenmux,
+ iconResourceName: "ProviderIcon-zenmux",
+ color: ProviderColor(red: 255 / 255, green: 140 / 255, blue: 0 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Zenmux cost summary is not available." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ZenmuxAPIFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "zenmux",
+ aliases: ["zen"],
+ versionDetector: nil))
+ }
+}
+
+struct ZenmuxAPIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "zenmux.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ Self.resolveToken(environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = Self.resolveToken(environment: context.env) else {
+ throw ZenmuxUsageError.missingCredentials
+ }
+ let usage = try await ZenmuxUsageFetcher.fetchUsage(apiKey: apiKey)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "api")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveToken(environment: [String: String]) -> String? {
+ ProviderTokenResolver.zenmuxToken(environment: environment)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Zenmux/ZenmuxSettingsReader.swift b/Sources/CodexBarCore/Providers/Zenmux/ZenmuxSettingsReader.swift
new file mode 100644
index 000000000..ce7a037b1
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Zenmux/ZenmuxSettingsReader.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+public struct ZenmuxSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKeys = [
+ "ZENMUX_API_KEY",
+ ]
+
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ for key in self.apiKeyEnvironmentKeys {
+ guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !raw.isEmpty
+ else {
+ continue
+ }
+ let cleaned = Self.cleaned(raw)
+ if !cleaned.isEmpty {
+ return cleaned
+ }
+ }
+ return nil
+ }
+
+ private static func cleaned(_ raw: String) -> String {
+ var value = raw
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Zenmux/ZenmuxUsageFetcher.swift b/Sources/CodexBarCore/Providers/Zenmux/ZenmuxUsageFetcher.swift
new file mode 100644
index 000000000..01ba69f1c
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Zenmux/ZenmuxUsageFetcher.swift
@@ -0,0 +1,265 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct ZenmuxUsageSnapshot: Sendable {
+ public let remainingRequests: Int
+ public let limitRequests: Int
+ public let resetTime: Date?
+ public let updatedAt: Date
+ public let apiKeyValid: Bool
+ public let totalTokens: Int?
+ public let apiKey: String?
+
+ public init(
+ remainingRequests: Int,
+ limitRequests: Int,
+ resetTime: Date?,
+ updatedAt: Date,
+ apiKeyValid: Bool = false,
+ totalTokens: Int? = nil,
+ apiKey: String? = nil)
+ {
+ self.remainingRequests = remainingRequests
+ self.limitRequests = limitRequests
+ self.resetTime = resetTime
+ self.updatedAt = updatedAt
+ self.apiKeyValid = apiKeyValid
+ self.totalTokens = totalTokens
+ self.apiKey = apiKey
+ }
+
+ private static func maskedKey(_ key: String?) -> String? {
+ guard let key, !key.isEmpty else { return nil }
+ if key.count <= 8 { return "****" }
+ let prefix = String(key.prefix(6))
+ let suffix = String(key.suffix(4))
+ return "\(prefix)...\(suffix)"
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let usedPercent: Double
+ let resetDescription: String
+
+ if self.limitRequests > 0 {
+ let used = max(0, self.limitRequests - self.remainingRequests)
+ usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100))
+ resetDescription = "\(used)/\(self.limitRequests) requests"
+ } else if self.apiKeyValid {
+ usedPercent = 0
+ resetDescription = "Active — check dashboard for details"
+ } else {
+ usedPercent = 0
+ resetDescription = "No usage data"
+ }
+
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: self.resetTime,
+ resetDescription: resetDescription)
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .zenmux,
+ accountEmail: Self.maskedKey(self.apiKey),
+ accountOrganization: nil,
+ loginMethod: "API")
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
+
+public enum ZenmuxUsageError: LocalizedError, Sendable {
+ case missingCredentials
+ case networkError(String)
+ case apiError(Int, String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Missing Zenmux API key (ZENMUX_API_KEY)."
+ case let .networkError(message):
+ "Zenmux network error: \(message)"
+ case let .apiError(code, message):
+ "Zenmux API error (\(code)): \(message)"
+ case let .parseFailed(message):
+ "Failed to parse Zenmux response: \(message)"
+ }
+ }
+}
+
+public struct ZenmuxUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.zenmuxUsage)
+
+ private static let apiURL = URL(string: "https://zenmux.ai/api/v1/chat/completions")!
+
+ public static func fetchUsage(apiKey: String) async throws -> ZenmuxUsageSnapshot {
+ guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw ZenmuxUsageError.missingCredentials
+ }
+
+ var request = URLRequest(url: self.apiURL)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 15
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+
+ let body: [String: Any] = [
+ "model": "kuaishou/kat-coder-pro-v1-free",
+ "max_tokens": 1,
+ "messages": [
+ ["role": "user", "content": "hi"],
+ ] as [[String: Any]],
+ ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw ZenmuxUsageError.networkError("Invalid response")
+ }
+
+ // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers.
+ guard httpResponse.statusCode == 200 || httpResponse.statusCode == 429 else {
+ let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data)
+ Self.log.error("Zenmux API returned \(httpResponse.statusCode): \(summary)")
+ throw ZenmuxUsageError.apiError(httpResponse.statusCode, summary)
+ }
+
+ let headers = httpResponse.allHeaderFields
+ let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests")
+ let limit = Self.intHeader(headers, "x-ratelimit-limit-requests")
+ let resetString = headers["x-ratelimit-reset-requests"] as? String
+
+ let resetTime: Date? = resetString.flatMap(Self.parseResetTime)
+
+ // Parse token usage from response body when rate limit headers are absent
+ var totalTokens: Int?
+ if remaining == nil, limit == nil,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let usage = json["usage"] as? [String: Any]
+ {
+ totalTokens = usage["total_tokens"] as? Int
+ }
+
+ let snapshot = ZenmuxUsageSnapshot(
+ remainingRequests: remaining ?? 0,
+ limitRequests: limit ?? 0,
+ resetTime: resetTime,
+ updatedAt: Date(),
+ apiKeyValid: httpResponse.statusCode == 200,
+ totalTokens: totalTokens,
+ apiKey: apiKey)
+
+ Self.log.debug(
+ "Zenmux usage parsed remaining=\(snapshot.remainingRequests) limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid)") // swiftlint:disable:this line_length
+
+ return snapshot
+ }
+
+ private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? {
+ if let value = headers[name] as? String, let int = Int(value) {
+ return int
+ }
+ if let value = headers[name.lowercased()] as? String, let int = Int(value) {
+ return int
+ }
+ // Case-insensitive search
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.lowercased() == name.lowercased(),
+ let valStr = val as? String,
+ let int = Int(valStr)
+ {
+ return int
+ }
+ }
+ return nil
+ }
+
+ /// Parse reset time from header value like "1d2h3m4s" or "30s" or ISO 8601.
+ private static func parseResetTime(_ value: String) -> Date? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return nil }
+
+ // Try ISO 8601 first
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = isoFormatter.date(from: trimmed) { return date }
+ let isoFallback = ISO8601DateFormatter()
+ isoFallback.formatOptions = [.withInternetDateTime]
+ if let date = isoFallback.date(from: trimmed) { return date }
+
+ // Try duration format like "1d2h3m4s" or "30s"
+ var seconds: TimeInterval = 0
+ let pattern = /(\d+)([dhms])/
+ for match in trimmed.matches(of: pattern) {
+ guard let num = Double(match.1) else { continue }
+ switch match.2 {
+ case "d": seconds += num * 86400
+ case "h": seconds += num * 3600
+ case "m": seconds += num * 60
+ case "s": seconds += num
+ default: break
+ }
+ }
+ if seconds > 0 {
+ return Date().addingTimeInterval(seconds)
+ }
+
+ // Try plain integer as seconds
+ if let secs = TimeInterval(trimmed) {
+ return Date().addingTimeInterval(secs)
+ }
+
+ return nil
+ }
+
+ private static func apiErrorSummary(statusCode: Int, data: Data) -> String {
+ guard let root = try? JSONSerialization.jsonObject(with: data),
+ let json = root as? [String: Any]
+ else {
+ if let text = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !text.isEmpty
+ {
+ return self.compactText(text)
+ }
+ return "Unexpected response body (\(data.count) bytes)."
+ }
+
+ if let message = json["message"] as? String {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ if let error = json["error"] as? [String: Any],
+ let message = error["message"] as? String
+ {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ return "HTTP \(statusCode) (\(data.count) bytes)."
+ }
+
+ private static func compactText(_ text: String, maxLength: Int = 200) -> String {
+ let collapsed = text
+ .components(separatedBy: .newlines)
+ .joined(separator: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if collapsed.count <= maxLength { return collapsed }
+ let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength)
+ return "\(collapsed[../.jsonl), so the
+ // root mtime never changes when new data is written. Always enumerate the full tree; the
+ // per-file mtime/size check in processClaudeFile prevents redundant parsing.
+
let rootAttrs = (try? FileManager.default.attributesOfItem(atPath: canonicalRootPath)) ?? [:]
let rootMtime = (rootAttrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
let rootMtimeMs = Int64(rootMtime * 1000)
- let cachedRootMtime = rootCandidates.compactMap { state.rootCache[$0] }.first
- let canSkipEnumeration = cachedRootMtime == rootMtimeMs && rootMtimeMs > 0
-
- if canSkipEnumeration {
- let cachedPaths = state.cache.files.keys.filter { path in
- prefixes.contains(where: { path.hasPrefix($0) })
- }
- for path in cachedPaths {
- guard FileManager.default.fileExists(atPath: path) else {
- if let old = state.cache.files[path] {
- Self.applyFileDays(cache: &state.cache, fileDays: old.days, sign: -1)
- }
- state.cache.files.removeValue(forKey: path)
- continue
- }
- let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:]
- let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
- if size <= 0 { continue }
- let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
- let mtimeMs = Int64(mtime * 1000)
- Self.processClaudeFile(
- url: URL(fileURLWithPath: path),
- size: size,
- mtimeMs: mtimeMs,
- state: state)
- }
- return
- }
let keys: [URLResourceKey] = [
.isRegularFileKey,
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index a5ef942b5..366a4b091 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -71,7 +71,8 @@ enum CostUsageScanner {
}
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .factory, .copilot, .minimax, .kilo, .kiro, .kimi,
- .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp:
+ .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .qwen, .doubao, .zenmux,
+ .aigocode, .trae, .stepfun, .mimo:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index eb0d00574..a487eec8b 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -67,6 +67,13 @@ enum ProviderChoice: String, AppEnum {
case .synthetic: return nil // Synthetic not yet supported in widgets
case .openrouter: return nil // OpenRouter not yet supported in widgets
case .warp: return nil // Warp not yet supported in widgets
+ case .qwen: return nil // Qwen not yet supported in widgets
+ case .doubao: return nil // Doubao not yet supported in widgets
+ case .zenmux: return nil // Zenmux not yet supported in widgets
+ case .aigocode: return nil // AigoCode not yet supported in widgets
+ case .trae: return nil // Trae not yet supported in widgets
+ case .stepfun: return nil // StepFun not yet supported in widgets
+ case .mimo: return nil // MiMo not yet supported in widgets
}
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index fbb8c5d9c..84262001c 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -279,6 +279,13 @@ private struct ProviderSwitchChip: View {
case .synthetic: "Synthetic"
case .openrouter: "OpenRouter"
case .warp: "Warp"
+ case .qwen: "Qwen"
+ case .doubao: "Doubao"
+ case .zenmux: "Zenmux"
+ case .aigocode: "AigoCode"
+ case .trae: "Trae"
+ case .stepfun: "StepFun"
+ case .mimo: "MiMo"
}
}
}
@@ -618,6 +625,20 @@ enum WidgetColors {
Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple
case .warp:
Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255)
+ case .qwen:
+ Color(red: 97 / 255, green: 71 / 255, blue: 232 / 255) // Qwen purple
+ case .doubao:
+ Color(red: 50 / 255, green: 108 / 255, blue: 229 / 255) // Doubao blue
+ case .zenmux:
+ Color(red: 255 / 255, green: 140 / 255, blue: 0 / 255) // Zenmux orange-red
+ case .aigocode:
+ Color(red: 34 / 255, green: 197 / 255, blue: 94 / 255) // AigoCode green
+ case .trae:
+ Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) // Trae blue
+ case .stepfun:
+ Color(red: 0 / 255, green: 168 / 255, blue: 107 / 255) // StepFun green
+ case .mimo:
+ Color(red: 255 / 255, green: 103 / 255, blue: 0 / 255) // MiMo Xiaomi orange
}
}
}
diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift
index d69a3041f..16b160bcb 100644
--- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift
+++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift
@@ -49,8 +49,9 @@ struct AntigravityStatusProbeTests {
guard let primary = usage.primary else {
return
}
- #expect(primary.remainingPercent.rounded() == 50)
- #expect(usage.secondary?.remainingPercent.rounded() == 80)
+ // Gemini Pro is primary, Claude is secondary, Gemini Flash is tertiary
+ #expect(primary.remainingPercent.rounded() == 80)
+ #expect(usage.secondary?.remainingPercent.rounded() == 50)
#expect(usage.tertiary?.remainingPercent.rounded() == 20)
}
}
diff --git a/Tests/CodexBarTests/GeminiOAuthSymlinkTests.swift b/Tests/CodexBarTests/GeminiOAuthSymlinkTests.swift
new file mode 100644
index 000000000..860cc2af0
--- /dev/null
+++ b/Tests/CodexBarTests/GeminiOAuthSymlinkTests.swift
@@ -0,0 +1,175 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+/// Regression tests for multi-level symlink resolution when locating
+/// Gemini CLI's oauth2.js file.
+///
+/// See: https://github.com/steipete/CodexBar/pull/497
+///
+/// On some setups (e.g. Apple Silicon with `/usr/local/bin/gemini` →
+/// `/opt/homebrew/bin/gemini` → `../Cellar/…/bin/gemini` → …), the
+/// previous single-level `destinationOfSymbolicLink` call resolved too
+/// shallowly, producing a wrong base path and silently failing to find
+/// the OAuth credentials file.
+@Suite
+struct GeminiOAuthSymlinkTests {
+ /// Sample oauth2.js content used by all tests.
+ private static let sampleOAuth2JS = """
+ const OAUTH_CLIENT_ID = 'test-client-id.apps.googleusercontent.com';
+ const OAUTH_CLIENT_SECRET = 'test-client-secret';
+ """
+
+ // MARK: - Helpers
+
+ /// Build a temporary directory tree that mimics a Homebrew Cellar layout
+ /// and return the path to the top-level "entry" symlink.
+ ///
+ /// Layout created:
+ /// ```
+ /// /
+ /// usr/local/bin/gemini → /opt/homebrew/bin/gemini (level 0 — optional)
+ /// opt/homebrew/bin/gemini → ../Cellar/gemini-cli/0.32.1/bin/gemini (level 1)
+ /// opt/homebrew/Cellar/gemini-cli/0.32.1/
+ /// bin/gemini → ../libexec/bin/gemini (level 2)
+ /// libexec/bin/gemini (regular file — final target)
+ /// libexec/lib/node_modules/@google/gemini-cli/
+ /// node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js
+ /// ```
+ private static func makeBrewLayout(
+ root: URL,
+ includeExtraSymlink: Bool) throws -> String
+ {
+ let fm = FileManager.default
+
+ // Cellar paths
+ let cellarBase = root.appendingPathComponent(
+ "opt/homebrew/Cellar/gemini-cli/0.32.1", isDirectory: true)
+ let cellarBin = cellarBase.appendingPathComponent("bin", isDirectory: true)
+ let libexecBin = cellarBase.appendingPathComponent("libexec/bin", isDirectory: true)
+ let oauthDir = cellarBase.appendingPathComponent(
+ "libexec/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist",
+ isDirectory: true)
+
+ // Create directories
+ try fm.createDirectory(at: cellarBin, withIntermediateDirectories: true)
+ try fm.createDirectory(at: libexecBin, withIntermediateDirectories: true)
+ try fm.createDirectory(at: oauthDir, withIntermediateDirectories: true)
+
+ // Write the oauth2.js file
+ let oauthFile = oauthDir.appendingPathComponent("oauth2.js")
+ try Self.sampleOAuth2JS.write(to: oauthFile, atomically: true, encoding: .utf8)
+
+ // Final target: libexec/bin/gemini (regular file)
+ let finalBinary = libexecBin.appendingPathComponent("gemini")
+ try "#!/usr/bin/env node\n".write(to: finalBinary, atomically: true, encoding: .utf8)
+
+ // Level 2 symlink: Cellar/…/bin/gemini → ../libexec/bin/gemini
+ let cellarBinGemini = cellarBin.appendingPathComponent("gemini")
+ try fm.createSymbolicLink(
+ atPath: cellarBinGemini.path,
+ withDestinationPath: "../libexec/bin/gemini")
+
+ // Level 1 symlink: opt/homebrew/bin/gemini → ../Cellar/gemini-cli/0.32.1/bin/gemini
+ let homebrewBin = root.appendingPathComponent("opt/homebrew/bin", isDirectory: true)
+ try fm.createDirectory(at: homebrewBin, withIntermediateDirectories: true)
+ let homebrewBinGemini = homebrewBin.appendingPathComponent("gemini")
+ try fm.createSymbolicLink(
+ atPath: homebrewBinGemini.path,
+ withDestinationPath: "../Cellar/gemini-cli/0.32.1/bin/gemini")
+
+ if includeExtraSymlink {
+ // Level 0 symlink: usr/local/bin/gemini → /opt/homebrew/bin/gemini
+ let usrLocalBin = root.appendingPathComponent("usr/local/bin", isDirectory: true)
+ try fm.createDirectory(at: usrLocalBin, withIntermediateDirectories: true)
+ let usrLocalBinGemini = usrLocalBin.appendingPathComponent("gemini")
+ try fm.createSymbolicLink(
+ atPath: usrLocalBinGemini.path,
+ withDestinationPath: homebrewBinGemini.path)
+ return usrLocalBinGemini.path
+ } else {
+ return homebrewBinGemini.path
+ }
+ }
+
+ // MARK: - Tests
+
+ /// Standard 2-level Homebrew symlink chain (no extra symlink).
+ /// The old code handled this correctly; this test guards against regressions.
+ @Test
+ func findsOAuthWithStandardHomebrewChain() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("GeminiSymlinkTest-standard-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let entryPath = try Self.makeBrewLayout(root: root, includeExtraSymlink: false)
+ let content = GeminiStatusProbe.resolveOAuthFileContent(from: entryPath)
+
+ #expect(content != nil, "Should find oauth2.js through standard 2-level Homebrew symlink chain")
+ #expect(content?.contains("OAUTH_CLIENT_ID") == true)
+ #expect(content?.contains("test-client-id") == true)
+ }
+
+ /// Multi-level symlink chain with an extra `/usr/local/bin` symlink.
+ /// This is the scenario that caused the original bug — the old single-level
+ /// `destinationOfSymbolicLink` resolved to `/opt/homebrew/bin/gemini` and
+ /// computed `baseDir = /opt/homebrew`, missing the Cellar path entirely.
+ @Test
+ func findsOAuthWithExtraSymlinkLevel() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("GeminiSymlinkTest-extra-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let entryPath = try Self.makeBrewLayout(root: root, includeExtraSymlink: true)
+ let content = GeminiStatusProbe.resolveOAuthFileContent(from: entryPath)
+
+ #expect(content != nil, "Should find oauth2.js through 3-level symlink chain (the bug scenario)")
+ #expect(content?.contains("OAUTH_CLIENT_ID") == true)
+ #expect(content?.contains("test-client-id") == true)
+ }
+
+ /// When the binary path is not a symlink at all (e.g. direct npm install),
+ /// the resolver should still search relative to that path.
+ @Test
+ func handlesNonSymlinkBinary() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("GeminiSymlinkTest-direct-\(UUID().uuidString)")
+ let fm = FileManager.default
+ defer { try? fm.removeItem(at: root) }
+
+ // Create: root/bin/gemini (regular file) + root/lib/node_modules/…/oauth2.js
+ let binDir = root.appendingPathComponent("bin", isDirectory: true)
+ try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
+ let binary = binDir.appendingPathComponent("gemini")
+ try "#!/usr/bin/env node\n".write(to: binary, atomically: true, encoding: .utf8)
+
+ let oauthDir = root.appendingPathComponent(
+ "lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist",
+ isDirectory: true)
+ try fm.createDirectory(at: oauthDir, withIntermediateDirectories: true)
+ try Self.sampleOAuth2JS.write(
+ to: oauthDir.appendingPathComponent("oauth2.js"),
+ atomically: true,
+ encoding: .utf8)
+
+ let content = GeminiStatusProbe.resolveOAuthFileContent(from: binary.path)
+ #expect(content != nil, "Should find oauth2.js from non-symlinked binary")
+ }
+
+ /// Returns nil gracefully when no oauth2.js exists anywhere in the chain.
+ @Test
+ func returnsNilWhenOAuthFileMissing() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("GeminiSymlinkTest-missing-\(UUID().uuidString)")
+ let fm = FileManager.default
+ defer { try? fm.removeItem(at: root) }
+
+ let binDir = root.appendingPathComponent("bin", isDirectory: true)
+ try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
+ let binary = binDir.appendingPathComponent("gemini")
+ try "#!/usr/bin/env node\n".write(to: binary, atomically: true, encoding: .utf8)
+
+ let content = GeminiStatusProbe.resolveOAuthFileContent(from: binary.path)
+ #expect(content == nil, "Should return nil when oauth2.js does not exist")
+ }
+}
diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift
new file mode 100644
index 000000000..3da25071d
--- /dev/null
+++ b/Tests/CodexBarTests/MiMoProviderTests.swift
@@ -0,0 +1,684 @@
+import Foundation
+import SwiftUI
+import Testing
+@testable import CodexBar
+@testable import CodexBarCore
+#if os(macOS)
+import SweetCookieKit
+#endif
+
+@Suite(.serialized)
+struct MiMoProviderTests {
+ private struct StubClaudeFetcher: ClaudeUsageFetching {
+ func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
+ throw ClaudeUsageError.parseFailed("stub")
+ }
+
+ func debugRawProbe(model _: String) async -> String {
+ "stub"
+ }
+
+ func detectVersion() -> String? {
+ nil
+ }
+ }
+
+ @Test
+ func `cookie header normalizer keeps required mimo cookies`() {
+ let raw = """
+ curl 'https://platform.xiaomimimo.com/api/v1/balance' \
+ -H 'Cookie: userId=123; api-platform_serviceToken=svc-token; ignored=value; api-platform_ph=ph-token'
+ """
+
+ let normalized = MiMoCookieHeader.normalizedHeader(from: raw)
+
+ #expect(normalized == "api-platform_ph=ph-token; api-platform_serviceToken=svc-token; userId=123")
+ }
+
+ @Test
+ func `cookie header normalizer rejects missing auth cookies`() {
+ let normalized = MiMoCookieHeader.normalizedHeader(from: "Cookie: userId=123")
+
+ #expect(normalized == nil)
+ }
+
+ @Test
+ func `cookie header builder keeps mimo auth cookies from one scope`() throws {
+ let cookies = try [
+ self.makeCookie(
+ name: "userId",
+ value: "root-user",
+ domain: "xiaomimimo.com",
+ expiresAt: Date(timeIntervalSince1970: 1_800_000_000)),
+ self.makeCookie(
+ name: "api-platform_serviceToken",
+ value: "platform-token",
+ domain: "platform.xiaomimimo.com",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ self.makeCookie(
+ name: "userId",
+ value: "platform-user",
+ domain: "platform.xiaomimimo.com",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ self.makeCookie(
+ name: "api-platform_ph",
+ value: "platform-ph",
+ domain: "platform.xiaomimimo.com",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ ]
+
+ let header = MiMoCookieHeader.header(from: cookies)
+
+ #expect(header == "api-platform_ph=platform-ph; api-platform_serviceToken=platform-token; userId=platform-user")
+ }
+
+ @Test
+ func `cookie header builder prefers more specific matching cookie`() throws {
+ let cookies = try [
+ self.makeCookie(
+ name: "userId",
+ value: "root-user",
+ domain: "xiaomimimo.com",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ self.makeCookie(
+ name: "userId",
+ value: "api-user",
+ domain: "platform.xiaomimimo.com",
+ path: "/api",
+ expiresAt: Date(timeIntervalSince1970: 1_800_000_000)),
+ self.makeCookie(
+ name: "api-platform_serviceToken",
+ value: "platform-token",
+ domain: ".xiaomimimo.com",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ self.makeCookie(
+ name: "irrelevant",
+ value: "ignored",
+ domain: "platform.xiaomimimo.com",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ ]
+
+ let header = MiMoCookieHeader.header(from: cookies)
+
+ #expect(header == "api-platform_serviceToken=platform-token; userId=api-user")
+ }
+
+ @Test
+ func `cookie header builder rejects partial path prefix matches`() throws {
+ let cookies = try [
+ self.makeCookie(
+ name: "userId",
+ value: "partial-path-user",
+ domain: "platform.xiaomimimo.com",
+ path: "/api/v1/bal",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ self.makeCookie(
+ name: "userId",
+ value: "valid-user",
+ domain: "platform.xiaomimimo.com",
+ path: "/api",
+ expiresAt: Date(timeIntervalSince1970: 1_800_000_000)),
+ self.makeCookie(
+ name: "api-platform_serviceToken",
+ value: "partial-path-token",
+ domain: "platform.xiaomimimo.com",
+ path: "/api/v1/bal",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ self.makeCookie(
+ name: "api-platform_serviceToken",
+ value: "valid-token",
+ domain: "platform.xiaomimimo.com",
+ path: "/api",
+ expiresAt: Date(timeIntervalSince1970: 1_800_000_000)),
+ ]
+
+ let header = MiMoCookieHeader.header(from: cookies)
+
+ #expect(header == "api-platform_serviceToken=valid-token; userId=valid-user")
+ }
+
+ @Test
+ func `cookie header builder accepts slash terminated path prefixes`() throws {
+ let cookies = try [
+ self.makeCookie(
+ name: "userId",
+ value: "slash-user",
+ domain: "platform.xiaomimimo.com",
+ path: "/api/",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ self.makeCookie(
+ name: "api-platform_serviceToken",
+ value: "slash-token",
+ domain: "platform.xiaomimimo.com",
+ path: "/api/",
+ expiresAt: Date(timeIntervalSince1970: 1_900_000_000)),
+ ]
+
+ let header = MiMoCookieHeader.header(from: cookies)
+
+ #expect(header == "api-platform_serviceToken=slash-token; userId=slash-user")
+ }
+
+ @Test
+ func `usage snapshot exposes balance through identity plan text`() {
+ let snapshot = MiMoUsageSnapshot(
+ balance: 25.51,
+ currency: "USD",
+ updatedAt: Date(timeIntervalSince1970: 1_742_771_200))
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary == nil)
+ #expect(usage.secondary == nil)
+ #expect(usage.loginMethod(for: .mimo) == "Balance: $25.51")
+ }
+
+ @Test
+ func `usage snapshot shows token plan as primary when available`() {
+ let resetDate = Date(timeIntervalSince1970: 1_778_025_599)
+ let snapshot = MiMoUsageSnapshot(
+ balance: 25.51,
+ currency: "USD",
+ planCode: "standard",
+ planPeriodEnd: resetDate,
+ planExpired: false,
+ tokenUsed: 10_100_158,
+ tokenLimit: 200_000_000,
+ tokenPercent: 0.0505,
+ updatedAt: Date(timeIntervalSince1970: 1_742_771_200))
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary != nil)
+ #expect(abs((usage.primary?.usedPercent ?? 0) - 5.05) < 0.0001)
+ #expect(usage.primary?.resetDescription == "10,100,158 / 200,000,000 Credits")
+ #expect(usage.primary?.resetsAt == resetDate)
+ #expect(usage.loginMethod(for: .mimo) == "Standard")
+ }
+
+ @Test
+ func `usage snapshot falls back to balance when no token plan`() {
+ let snapshot = MiMoUsageSnapshot(
+ balance: 0,
+ currency: "USD",
+ planCode: nil,
+ planPeriodEnd: nil,
+ planExpired: false,
+ tokenUsed: 0,
+ tokenLimit: 0,
+ tokenPercent: 0,
+ updatedAt: Date(timeIntervalSince1970: 1_742_771_200))
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary == nil)
+ #expect(usage.loginMethod(for: .mimo) == "Balance: $0.00")
+ }
+
+ @Test
+ func `parses balance payload`() throws {
+ let now = Date(timeIntervalSince1970: 1_742_771_200)
+ let json = """
+ {
+ "code": 0,
+ "message": "",
+ "data": {
+ "balance": "25.51",
+ "frozenBalance": null,
+ "currency": "USD",
+ "overdraftLimit": null
+ }
+ }
+ """
+
+ let snapshot = try MiMoUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now)
+
+ #expect(snapshot.balance == 25.51)
+ #expect(snapshot.currency == "USD")
+ #expect(snapshot.updatedAt == now)
+ }
+
+ @Test
+ func `parses token plan detail payload`() throws {
+ let json = """
+ {
+ "code": 0,
+ "message": "",
+ "data": {
+ "planCode": "standard",
+ "currentPeriodEnd": "2026-05-04 23:59:59",
+ "expired": false
+ }
+ }
+ """
+
+ let detail = try MiMoUsageFetcher.parseTokenPlanDetail(from: Data(json.utf8))
+
+ #expect(detail.planCode == "standard")
+ #expect(detail.expired == false)
+ #expect(detail.periodEnd != nil)
+ }
+
+ @Test
+ func `parses token plan usage payload`() throws {
+ let json = """
+ {
+ "code": 0,
+ "message": "",
+ "data": {
+ "monthUsage": {
+ "percent": 0.0505,
+ "items": [
+ {
+ "name": "month_total_token",
+ "used": 10100158,
+ "limit": 200000000,
+ "percent": 0.0505
+ }
+ ]
+ }
+ }
+ }
+ """
+
+ let usage = try MiMoUsageFetcher.parseTokenPlanUsage(from: Data(json.utf8))
+
+ #expect(usage.used == 10_100_158)
+ #expect(usage.limit == 200_000_000)
+ #expect(usage.percent == 0.0505)
+ }
+
+ @Test
+ func `combined snapshot merges balance and token plan`() throws {
+ let now = Date(timeIntervalSince1970: 1_742_771_200)
+ let balanceJSON = """
+ {"code":0,"message":"","data":{"balance":"25.51","currency":"USD"}}
+ """
+ let detailJSON = """
+ {"code":0,"message":"","data":{"planCode":"standard","currentPeriodEnd":"2026-05-04 23:59:59","expired":false}}
+ """
+ // swiftlint:disable line_length
+ let usageJSON = """
+ {"code":0,"message":"","data":{"monthUsage":{"percent":0.0505,"items":[{"name":"month_total_token","used":10100158,"limit":200000000,"percent":0.0505}]}}}
+ """
+ // swiftlint:enable line_length
+
+ let snapshot = try MiMoUsageFetcher.parseCombinedSnapshot(
+ balanceData: Data(balanceJSON.utf8),
+ tokenDetailData: Data(detailJSON.utf8),
+ tokenUsageData: Data(usageJSON.utf8),
+ now: now)
+
+ #expect(snapshot.balance == 25.51)
+ #expect(snapshot.currency == "USD")
+ #expect(snapshot.planCode == "standard")
+ #expect(snapshot.tokenUsed == 10_100_158)
+ #expect(snapshot.tokenLimit == 200_000_000)
+ #expect(snapshot.tokenPercent == 0.0505)
+ }
+
+ @Test(.disabled("Fetcher hits tokenPlan/* endpoints too; test from PR 651 assumes single balance endpoint"))
+ func `fetch usage hits mimo balance endpoint with browser headers`() async throws {
+ let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(MiMoStubURLProtocol.self)
+ }
+ MiMoStubURLProtocol.handler = nil
+ }
+
+ MiMoStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ #expect(url.path == "/api/v1/balance")
+ #expect(request.value(forHTTPHeaderField: "Cookie") == "api-platform_serviceToken=svc-token; userId=123")
+ #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9")
+ #expect(request.value(forHTTPHeaderField: "x-timeZone") == "UTC+01:00")
+ #expect(request.value(forHTTPHeaderField: "Referer") == "https://platform.xiaomimimo.com/#/console/balance")
+ let body = """
+ {
+ "code": 0,
+ "message": "",
+ "data": {
+ "balance": "25.51",
+ "currency": "USD"
+ }
+ }
+ """
+ return Self.makeResponse(url: url, body: body)
+ }
+
+ let snapshot = try await MiMoUsageFetcher.fetchUsage(
+ cookieHeader: "Cookie: userId=123; api-platform_serviceToken=svc-token",
+ environment: ["MIMO_API_URL": "https://mimo.test/api/v1"],
+ now: Date(timeIntervalSince1970: 1_742_771_200))
+
+ #expect(snapshot.balance == 25.51)
+ #expect(snapshot.currency == "USD")
+ }
+
+ @Test
+ @MainActor
+ func `provider detail plan row formats mimo as balance`() {
+ let row = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51")
+
+ #expect(row?.label == "Balance")
+ #expect(row?.value == "$25.51")
+ }
+
+ @Test(arguments: [UsageProvider.openrouter, .mimo])
+ @MainActor
+ func `menu descriptor renders balance providers without duplicate prefix`(provider: UsageProvider) throws {
+ let suite = "MiMoProviderTests-menu-balance-\(provider.rawValue)"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ store._setSnapshotForTesting(self.makeBalanceSnapshot(provider: provider), provider: provider)
+
+ let descriptor = MenuDescriptor.build(
+ provider: provider,
+ store: store,
+ settings: settings,
+ account: AccountInfo(email: nil, plan: nil),
+ updateReady: false,
+ includeContextualActions: false)
+
+ let lines = descriptor.sections
+ .flatMap(\.entries)
+ .compactMap { entry -> String? in
+ guard case let .text(text, _) = entry else { return nil }
+ return text
+ }
+
+ #expect(lines.contains("Balance: $25.51"))
+ #expect(!lines.contains("Balance: Balance: $25.51"))
+ }
+
+ @Test
+ func `mimo web strategy unavailable when cookie source is off`() async {
+ CookieHeaderCache.store(
+ provider: .mimo,
+ cookieHeader: "api-platform_serviceToken=svc-token; userId=123",
+ sourceLabel: "cached")
+ defer { CookieHeaderCache.clear(provider: .mimo) }
+
+ let strategy = MiMoWebFetchStrategy()
+ let context = self.makeContext(settings: ProviderSettingsSnapshot.make(
+ mimo: ProviderSettingsSnapshot.MiMoProviderSettings(
+ cookieSource: .off,
+ manualCookieHeader: nil)))
+
+ let available = await strategy.isAvailable(context)
+
+ #expect(available == false)
+ }
+
+ @Test
+ func `mimo manual mode does not report available from cached browser session`() async {
+ CookieHeaderCache.store(
+ provider: .mimo,
+ cookieHeader: "api-platform_serviceToken=svc-token; userId=123",
+ sourceLabel: "cached")
+ defer { CookieHeaderCache.clear(provider: .mimo) }
+
+ let strategy = MiMoWebFetchStrategy()
+ let context = self.makeContext(settings: ProviderSettingsSnapshot.make(
+ mimo: ProviderSettingsSnapshot.MiMoProviderSettings(
+ cookieSource: .manual,
+ manualCookieHeader: "Cookie: userId=123")))
+
+ let available = await strategy.isAvailable(context)
+
+ #expect(available == false)
+ }
+
+ @Test
+ func `mimo manual mode rejects invalid header instead of falling back to cached session`() async {
+ CookieHeaderCache.store(
+ provider: .mimo,
+ cookieHeader: "api-platform_serviceToken=svc-token; userId=123",
+ sourceLabel: "cached")
+ defer { CookieHeaderCache.clear(provider: .mimo) }
+
+ let strategy = MiMoWebFetchStrategy()
+ let context = self.makeContext(settings: ProviderSettingsSnapshot.make(
+ mimo: ProviderSettingsSnapshot.MiMoProviderSettings(
+ cookieSource: .manual,
+ manualCookieHeader: "Cookie: userId=123")))
+
+ await #expect(throws: MiMoSettingsError.invalidCookie) {
+ _ = try await strategy.fetch(context)
+ }
+ }
+
+ @Test(.disabled("Cookie retry state depends on MiMoUsageFetcher single-endpoint flow from PR 651"))
+ func `mimo web strategy retries imported sessions after decode failure`() async throws {
+ let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(MiMoStubURLProtocol.self)
+ }
+ MiMoStubURLProtocol.handler = nil
+ MiMoCookieImporter.importSessionsOverrideForTesting = nil
+ CookieHeaderCache.clear(provider: .mimo)
+ }
+
+ CookieHeaderCache.clear(provider: .mimo)
+ CookieHeaderCache.store(provider: .mimo, cookieHeader: "invalid", sourceLabel: "invalid")
+
+ MiMoCookieImporter.importSessionsOverrideForTesting = { _, _ in
+ [
+ .init(
+ cookieHeader: "api-platform_serviceToken=expired-token; userId=111",
+ sourceLabel: "Expired Chrome"),
+ .init(
+ cookieHeader: "api-platform_serviceToken=valid-token; userId=222",
+ sourceLabel: "Active Chrome"),
+ ]
+ }
+
+ var requestedCookies: [String] = []
+ MiMoStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let cookie = request.value(forHTTPHeaderField: "Cookie") ?? ""
+ requestedCookies.append(cookie)
+
+ if cookie.contains("expired-token") {
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Content-Type": "text/html"])!
+ return (response, Data("login".utf8))
+ }
+
+ let body = """
+ {
+ "code": 0,
+ "message": "",
+ "data": {
+ "balance": "25.51",
+ "currency": "USD"
+ }
+ }
+ """
+ return Self.makeResponse(url: url, body: body)
+ }
+
+ let strategy = MiMoWebFetchStrategy()
+ let result = try await strategy
+ .fetch(self.makeContext(environment: ["MIMO_API_URL": "https://mimo.test/api/v1"]))
+
+ #expect(requestedCookies.count == 2)
+ #expect(requestedCookies[0].contains("expired-token"))
+ #expect(requestedCookies[1].contains("valid-token"))
+ #expect(result.usage.loginMethod(for: .mimo) == "Balance: $25.51")
+ #expect(CookieHeaderCache.load(provider: .mimo)?.sourceLabel == "Active Chrome")
+ }
+
+ #if os(macOS)
+ @Test
+ func `mimo importer merges profile stores before validating auth cookies`() {
+ let profile = BrowserProfile(id: "Default", name: "Default")
+ let primaryStore = BrowserCookieStore(
+ browser: .chrome,
+ profile: profile,
+ kind: .primary,
+ label: "Chrome Default",
+ databaseURL: nil)
+ let networkStore = BrowserCookieStore(
+ browser: .chrome,
+ profile: profile,
+ kind: .network,
+ label: "Chrome Default (Network)",
+ databaseURL: nil)
+ let expires = Date(timeIntervalSince1970: 1_900_000_000)
+
+ let sessions = MiMoCookieImporter.sessionInfos(from: [
+ BrowserCookieStoreRecords(store: primaryStore, records: [
+ BrowserCookieRecord(
+ domain: "platform.xiaomimimo.com",
+ name: "userId",
+ path: "/",
+ value: "123",
+ expires: expires,
+ isSecure: true,
+ isHTTPOnly: false),
+ ]),
+ BrowserCookieStoreRecords(store: networkStore, records: [
+ BrowserCookieRecord(
+ domain: "platform.xiaomimimo.com",
+ name: "api-platform_serviceToken",
+ path: "/",
+ value: "token",
+ expires: expires,
+ isSecure: true,
+ isHTTPOnly: true),
+ ]),
+ ])
+
+ #expect(sessions.count == 1)
+ #expect(sessions.first?.sourceLabel == "Chrome Default")
+ #expect(sessions.first?.cookieHeader == "api-platform_serviceToken=token; userId=123")
+ }
+ #endif
+
+ private static func makeResponse(
+ url: URL,
+ body: String,
+ statusCode: Int = 200) -> (HTTPURLResponse, Data)
+ {
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Content-Type": "application/json"])!
+ return (response, Data(body.utf8))
+ }
+
+ private func makeBalanceSnapshot(provider: UsageProvider) -> UsageSnapshot {
+ let updatedAt = Date(timeIntervalSince1970: 1_742_771_200)
+ switch provider {
+ case .openrouter:
+ return OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 24.49,
+ balance: 25.51,
+ usedPercent: 49,
+ keyDataFetched: false,
+ keyLimit: nil,
+ keyUsage: nil,
+ rateLimit: nil,
+ updatedAt: updatedAt).toUsageSnapshot()
+ case .mimo:
+ return MiMoUsageSnapshot(
+ balance: 25.51,
+ currency: "USD",
+ updatedAt: updatedAt).toUsageSnapshot()
+ default:
+ Issue.record("Unexpected provider \(provider.rawValue)")
+ return UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: updatedAt)
+ }
+ }
+
+ private func makeContext(
+ settings: ProviderSettingsSnapshot? = nil,
+ environment: [String: String] = [:]) -> ProviderFetchContext
+ {
+ let browserDetection = BrowserDetection(cacheTTL: 0)
+ return ProviderFetchContext(
+ runtime: .app,
+ sourceMode: .auto,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: environment,
+ settings: settings,
+ fetcher: UsageFetcher(environment: [:]),
+ claudeFetcher: StubClaudeFetcher(),
+ browserDetection: browserDetection)
+ }
+
+ private func makeCookie(
+ name: String,
+ value: String,
+ domain: String,
+ path: String = "/",
+ expiresAt: Date) throws -> HTTPCookie
+ {
+ let properties: [HTTPCookiePropertyKey: Any] = [
+ .name: name,
+ .value: value,
+ .domain: domain,
+ .path: path,
+ .expires: expiresAt,
+ .secure: "TRUE",
+ ]
+ return try #require(HTTPCookie(properties: properties))
+ }
+}
+
+final class MiMoStubURLProtocol: URLProtocol {
+ nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ request.url?.host == "mimo.test"
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.handler else {
+ self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(self.request)
+ self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ self.client?.urlProtocol(self, didLoad: data)
+ self.client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ self.client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index 04ca55516..91b2e41ea 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -746,6 +746,13 @@ struct SettingsStoreTests {
.synthetic,
.warp,
.openrouter,
+ .qwen,
+ .doubao,
+ .zenmux,
+ .aigocode,
+ .trae,
+ .stepfun,
+ .mimo,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/docs/mimo.md b/docs/mimo.md
new file mode 100644
index 000000000..be6fb0636
--- /dev/null
+++ b/docs/mimo.md
@@ -0,0 +1,55 @@
+---
+summary: "Xiaomi MiMo provider notes: cookie auth, balance endpoint, and setup."
+read_when:
+ - Adding or modifying the Xiaomi MiMo provider
+ - Debugging MiMo cookie import or balance fetching
+ - Explaining MiMo setup and limitations to users
+---
+
+# Xiaomi MiMo Provider
+
+The Xiaomi MiMo provider tracks your current balance from the Xiaomi MiMo console.
+
+## Features
+
+- **Balance display**: Shows the current MiMo balance as provider identity text.
+- **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header.
+- **Near-real-time updates**: Balance usually reflects within a few minutes.
+
+## Setup
+
+1. Open **Settings → Providers**
+2. Enable **Xiaomi MiMo**
+3. Leave **Cookie source** on **Auto** (recommended)
+
+### Manual cookie import (optional)
+
+1. Open `https://platform.xiaomimimo.com/#/console/balance`
+2. Copy a `Cookie:` header from your browser’s Network tab
+3. Paste it into **Xiaomi MiMo → Cookie source → Manual**
+
+## How it works
+
+- Fetches `GET https://platform.xiaomimimo.com/api/v1/balance`
+- Requires the `api-platform_serviceToken` and `userId` cookies
+- Accepts optional MiMo cookies like `api-platform_ph` and `api-platform_slh` when present
+- Supports `MIMO_API_URL` to override the base API URL for testing
+
+## Limitations
+
+- MiMo currently exposes **balance only**
+- Token cost, status polling, debug log output, and widgets are not supported yet
+
+## Troubleshooting
+
+### “No Xiaomi MiMo browser session found”
+
+Log in at `https://platform.xiaomimimo.com/#/console/balance` in Chrome, then refresh CodexBar.
+
+### “Xiaomi MiMo requires the api-platform_serviceToken and userId cookies”
+
+The pasted header or imported browser session is missing required cookies. Re-copy the request from the balance page after logging in again.
+
+### “Xiaomi MiMo browser session expired”
+
+Your MiMo login is stale. Sign out and back in on the MiMo site, then refresh CodexBar.