diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 5f685af23..d54d9940d 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -785,6 +785,13 @@ extension UsageMenuCardView.Model {
return notes
}
+ if input.provider == .mimo, input.snapshot != nil {
+ return [
+ "Balance updates in near-real time (up to 5 min lag)",
+ "Daily billing data finalizes at 07:00 UTC",
+ ]
+ }
+
guard input.provider == .openrouter,
let openRouter = input.snapshot?.openRouterUsage
else {
diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift
index 05aa55fff..f926abfb0 100644
--- a/Sources/CodexBar/MenuDescriptor.swift
+++ b/Sources/CodexBar/MenuDescriptor.swift
@@ -247,7 +247,18 @@ struct MenuDescriptor {
entries.append(.text("Activity: \(detail)", .secondary))
}
} else if let loginMethodText, !loginMethodText.isEmpty {
- entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText))", .secondary))
+ if provider == .openrouter || provider == .mimo {
+ let balanceValue = loginMethodText
+ .replacingOccurrences(
+ of: #"(?i)^\s*balance:\s*"#,
+ with: "",
+ options: [.regularExpression])
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let value = balanceValue.isEmpty ? loginMethodText : balanceValue
+ entries.append(.text("Balance: \(AccountFormatter.plan(value))", .secondary))
+ } else {
+ entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText))", .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/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/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 9cb99850b..1da4852d5 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -36,6 +36,7 @@ enum ProviderImplementationRegistry {
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
+ case .mimo: MiMoProviderImplementation()
}
}
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/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 47efc5b63..51a55ed74 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1158,6 +1158,7 @@ extension UsageStore {
.kimi: "Kimi debug log not yet implemented",
.kimik2: "Kimi K2 debug log not yet implemented",
.jetbrains: "JetBrains AI debug log not yet implemented",
+ .mimo: "Xiaomi MiMo debug log not yet implemented",
]
let buildText = {
switch provider {
@@ -1231,7 +1232,7 @@ extension UsageStore {
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
- .kimik2, .jetbrains:
+ .kimik2, .jetbrains, .mimo:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index 937b37aa0..94b247002 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -165,6 +165,13 @@ struct TokenAccountCLIContext {
kimi: ProviderSettingsSnapshot.KimiProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
+ case .mimo:
+ let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
+ let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
+ return self.makeSnapshot(
+ mimo: ProviderSettingsSnapshot.MiMoProviderSettings(
+ cookieSource: cookieSource,
+ manualCookieHeader: cookieHeader))
case .zai:
return self.makeSnapshot(
zai: ProviderSettingsSnapshot.ZaiProviderSettings(apiRegion: self.resolveZaiRegion(config)))
@@ -196,7 +203,8 @@ struct TokenAccountCLIContext {
augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil,
amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil,
ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
- jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot
+ jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
+ mimo: ProviderSettingsSnapshot.MiMoProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
@@ -212,7 +220,8 @@ struct TokenAccountCLIContext {
augment: augment,
amp: amp,
ollama: ollama,
- jetbrains: jetbrains)
+ jetbrains: jetbrains,
+ mimo: mimo)
}
func environment(
diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift
new file mode 100644
index 000000000..cf7e0260f
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift
@@ -0,0 +1,237 @@
+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: "; ")
+ }
+
+ 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)
+ }
+}
+
+#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..4f0a6a042
--- /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: "Balance",
+ weeklyLabel: "Window",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Xiaomi MiMo 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..1a371ec5c
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift
@@ -0,0 +1,140 @@
+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 url = MiMoSettingsReader.apiURL(environment: environment).appendingPathComponent("balance")
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.timeoutInterval = Self.requestTimeout
+ request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
+ request.setValue(normalizedCookie, 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 try self.parseUsageSnapshot(from: data, now: now)
+ }
+
+ static func parseUsageSnapshot(from data: Data, now: Date = Date()) throws -> MiMoUsageSnapshot {
+ let decoder = JSONDecoder()
+ let response = try decoder.decode(Response.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)
+ }
+
+ private struct Response: Decodable {
+ let code: Int
+ let message: String?
+ let data: Payload?
+ }
+
+ private struct Payload: Decodable {
+ let balance: String
+ let currency: String
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift
new file mode 100644
index 000000000..51b812e2d
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift
@@ -0,0 +1,33 @@
+import Foundation
+
+public struct MiMoUsageSnapshot: Sendable {
+ public let balance: Double
+ public let currency: String
+ public let updatedAt: Date
+
+ public init(balance: Double, currency: String, updatedAt: Date) {
+ self.balance = balance
+ self.currency = currency
+ 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 identity = ProviderIdentitySnapshot(
+ providerID: .mimo,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: "Balance: \(balanceText)")
+
+ return UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 0596617b7..b95bd5aab 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -76,6 +76,7 @@ public enum ProviderDescriptorRegistry {
.synthetic: SyntheticProviderDescriptor.descriptor,
.openrouter: OpenRouterProviderDescriptor.descriptor,
.warp: WarpProviderDescriptor.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 c5d9af4f7..41405df42 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -18,7 +18,8 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: AugmentProviderSettings? = nil,
amp: AmpProviderSettings? = nil,
ollama: OllamaProviderSettings? = nil,
- jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot
+ jetbrains: JetBrainsProviderSettings? = nil,
+ mimo: MiMoProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
debugMenuEnabled: debugMenuEnabled,
@@ -37,7 +38,8 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: augment,
amp: amp,
ollama: ollama,
- jetbrains: jetbrains)
+ jetbrains: jetbrains,
+ mimo: mimo)
}
public struct CodexProviderSettings: Sendable {
@@ -209,6 +211,16 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ 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 let debugMenuEnabled: Bool
public let debugKeepCLISessionsAlive: Bool
public let codex: CodexProviderSettings?
@@ -226,6 +238,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let amp: AmpProviderSettings?
public let ollama: OllamaProviderSettings?
public let jetbrains: JetBrainsProviderSettings?
+ public let mimo: MiMoProviderSettings?
public var jetbrainsIDEBasePath: String? {
self.jetbrains?.ideBasePath
@@ -248,7 +261,8 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: AugmentProviderSettings?,
amp: AmpProviderSettings?,
ollama: OllamaProviderSettings?,
- jetbrains: JetBrainsProviderSettings? = nil)
+ jetbrains: JetBrainsProviderSettings? = nil,
+ mimo: MiMoProviderSettings? = nil)
{
self.debugMenuEnabled = debugMenuEnabled
self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive
@@ -267,6 +281,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.amp = amp
self.ollama = ollama
self.jetbrains = jetbrains
+ self.mimo = mimo
}
}
@@ -286,6 +301,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case amp(ProviderSettingsSnapshot.AmpProviderSettings)
case ollama(ProviderSettingsSnapshot.OllamaProviderSettings)
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
+ case mimo(ProviderSettingsSnapshot.MiMoProviderSettings)
}
public struct ProviderSettingsSnapshotBuilder: Sendable {
@@ -306,6 +322,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var amp: ProviderSettingsSnapshot.AmpProviderSettings?
public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings?
public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings?
+ public var mimo: ProviderSettingsSnapshot.MiMoProviderSettings?
public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) {
self.debugMenuEnabled = debugMenuEnabled
@@ -329,6 +346,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .amp(value): self.amp = value
case let .ollama(value): self.ollama = value
case let .jetbrains(value): self.jetbrains = value
+ case let .mimo(value): self.mimo = value
}
}
@@ -350,6 +368,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
augment: self.augment,
amp: self.amp,
ollama: self.ollama,
- jetbrains: self.jetbrains)
+ jetbrains: self.jetbrains,
+ mimo: self.mimo)
}
}
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 039f7a3f3..fef0a4766 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -26,6 +26,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case synthetic
case warp
case openrouter
+ case mimo
}
// swiftformat:enable sortDeclarations
@@ -54,6 +55,7 @@ public enum IconStyle: Sendable, CaseIterable {
case synthetic
case warp
case openrouter
+ case mimo
case combined
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 0e6767e15..4462af10a 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -72,7 +72,7 @@ enum CostUsageScanner {
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .alibaba, .factory, .copilot, .minimax, .kilo,
.kiro, .kimi,
- .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp:
+ .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .mimo:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index c828a2695..70a3e2b0a 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -71,6 +71,7 @@ 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 .mimo: return nil // Xiaomi MiMo not yet supported in widgets
}
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 3b0dd2d27..4dbac1464 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -280,6 +280,7 @@ private struct ProviderSwitchChip: View {
case .synthetic: "Synthetic"
case .openrouter: "OpenRouter"
case .warp: "Warp"
+ case .mimo: "MiMo"
}
}
}
@@ -621,6 +622,8 @@ 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 .mimo:
+ .primary
}
}
}
diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift
new file mode 100644
index 000000000..44da54155
--- /dev/null
+++ b/Tests/CodexBarTests/MiMoProviderTests.swift
@@ -0,0 +1,562 @@
+import Foundation
+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 `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 `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
+ 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 a7cfb81b1..c0a1de3a0 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -767,6 +767,7 @@ struct SettingsStoreTests {
.synthetic,
.warp,
.openrouter,
+ .mimo,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/docs/configuration.md b/docs/configuration.md
index fecfd198a..7d5003827 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -72,7 +72,7 @@ All provider fields are optional unless noted.
## Provider IDs
Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`):
-`codex`, `claude`, `cursor`, `opencode`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `ollama`, `synthetic`, `warp`, `openrouter`.
+`codex`, `claude`, `cursor`, `opencode`, `alibaba`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `ollama`, `synthetic`, `warp`, `openrouter`, `mimo`.
## Ordering
The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering.
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.
diff --git a/docs/providers.md b/docs/providers.md
index 63f3aeaa0..7e639199d 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -1,5 +1,5 @@
---
-summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)."
+summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Xiaomi MiMo)."
read_when:
- Adding or modifying provider fetch/parsing
- Adjusting provider labels, toggles, or metadata
@@ -39,6 +39,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Warp | API token (config/env) → GraphQL request limits (`api`). |
| Ollama | Web settings page via browser cookies (`web`). |
| OpenRouter | API token (config, overrides env) → credits API (`api`). |
+| Xiaomi MiMo | Browser cookies → balance endpoint (`web`). |
## Codex
- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -182,4 +183,11 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Status: `https://status.openrouter.ai` (link only, no auto-polling yet).
- Details: `docs/openrouter.md`.
+## Xiaomi MiMo
+- Browser-cookie based provider for the Xiaomi MiMo balance console.
+- Endpoint: `GET https://platform.xiaomimimo.com/api/v1/balance`.
+- Cookie source can be `auto` (Chrome-family import) or `manual` (paste a `Cookie:` header from `platform.xiaomimimo.com`).
+- Status: none yet.
+- Details: `docs/mimo.md`.
+
See also: `docs/provider.md` for architecture notes.