diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 6356c51a6..1afd5f05a 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -1048,7 +1048,7 @@ extension UsageMenuCardView.Model {
{
primaryDetailText = detail
}
- if input.provider == .alibaba,
+ if input.provider == .alibaba || input.provider == .mistral,
let detail = primary.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
diff --git a/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift
new file mode 100644
index 000000000..1949d2ed0
--- /dev/null
+++ b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift
@@ -0,0 +1,106 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct MistralProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .mistral
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { _ in "web" }
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.mistralCookieSource
+ _ = settings.mistralCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .mistral(context.settings.mistralSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool {
+ guard support.requiresManualCookieSource else { return true }
+ if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true }
+ return context.settings.mistralCookieSource == .manual
+ }
+
+ @MainActor
+ func applyTokenAccountCookieSource(settings: SettingsStore) {
+ if settings.mistralCookieSource != .manual {
+ settings.mistralCookieSource = .manual
+ }
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.mistralCookieSource.rawValue },
+ set: { raw in
+ context.settings.mistralCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let cookieOptions = ProviderCookieSourceUI.options(
+ allowsOff: false,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+
+ let cookieSubtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.mistralCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Automatic imports browser cookies from admin.mistral.ai.",
+ manual: "Paste a Cookie header captured from the billing page.",
+ off: "Mistral cookies are disabled.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "mistral-cookie-source",
+ title: "Cookie source",
+ subtitle: "Automatic imports browser cookies from admin.mistral.ai.",
+ dynamicSubtitle: cookieSubtitle,
+ binding: cookieBinding,
+ options: cookieOptions,
+ isVisible: nil,
+ onChange: nil,
+ trailingText: {
+ guard let entry = CookieHeaderCache.load(provider: .mistral) else { return nil }
+ let when = entry.storedAt.relativeDescription()
+ return "Cached: \(entry.sourceLabel) • \(when)"
+ }),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "mistral-cookie-header",
+ title: "Cookie header",
+ subtitle: "Paste the Cookie header from a request to admin.mistral.ai. "
+ + "Must contain an ory_session_* cookie.",
+ kind: .secure,
+ placeholder: "ory_session_…=…; csrftoken=…",
+ binding: context.stringBinding(\.mistralCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "mistral-open-console",
+ title: "Open Mistral Admin",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://admin.mistral.ai/organization/usage") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.mistralCookieSource == .manual },
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift
new file mode 100644
index 000000000..e99485517
--- /dev/null
+++ b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift
@@ -0,0 +1,64 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var mistralCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .mistral)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .mistral) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .mistral, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var mistralCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .mistral, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .mistral) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .mistral, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+
+ func ensureMistralCookieLoaded() {}
+}
+
+extension SettingsStore {
+ func mistralSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot
+ .MistralProviderSettings
+ {
+ ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: self.mistralSnapshotCookieSource(tokenOverride: tokenOverride),
+ manualCookieHeader: self.mistralSnapshotCookieHeader(tokenOverride: tokenOverride))
+ }
+
+ private func mistralSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String {
+ let fallback = self.mistralCookieHeader
+ guard let support = TokenAccountSupportCatalog.support(for: .mistral),
+ case .cookieHeader = support.injection
+ else {
+ return fallback
+ }
+ guard let account = ProviderTokenAccountSelection.selectedAccount(
+ provider: .mistral,
+ settings: self,
+ override: tokenOverride)
+ else {
+ return fallback
+ }
+ return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support)
+ }
+
+ private func mistralSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource {
+ let fallback = self.mistralCookieSource
+ guard let support = TokenAccountSupportCatalog.support(for: .mistral),
+ support.requiresManualCookieSource
+ else {
+ return fallback
+ }
+ if self.tokenAccounts(for: .mistral).isEmpty { return fallback }
+ return .manual
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 6dd8c45c4..1b4950ec3 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -39,6 +39,7 @@ enum ProviderImplementationRegistry {
case .warp: WarpProviderImplementation()
case .perplexity: PerplexityProviderImplementation()
case .abacus: AbacusProviderImplementation()
+ case .mistral: MistralProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-mistral.svg b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg
new file mode 100644
index 000000000..c946b5225
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 36cc0bf2a..a6370889e 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -880,7 +880,7 @@ extension UsageStore {
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
- .kimik2, .jetbrains, .perplexity, .abacus:
+ .kimik2, .jetbrains, .perplexity, .abacus, .mistral:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift
index 211cb9d52..e22260c8d 100644
--- a/Sources/CodexBarCLI/CLIRenderer.swift
+++ b/Sources/CodexBarCLI/CLIRenderer.swift
@@ -199,7 +199,7 @@ enum CLIRenderer {
now: Date,
lines: inout [String])
{
- if provider == .warp || provider == .kilo {
+ if provider == .warp || provider == .kilo || provider == .mistral {
if let reset = self.resetLineForDetailBackedWindow(window: window, style: context.resetStyle, now: now) {
lines.append(self.subtleLine(reset, useColor: context.useColor))
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index e43073e82..5d4f9cba2 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -193,6 +193,13 @@ struct TokenAccountCLIContext {
abacus: ProviderSettingsSnapshot.AbacusProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
+ case .mistral:
+ let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
+ let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
+ return self.makeSnapshot(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: cookieSource,
+ manualCookieHeader: cookieHeader))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
return nil
}
@@ -215,7 +222,8 @@ struct TokenAccountCLIContext {
ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil,
- abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot
+ abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil,
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
@@ -234,7 +242,8 @@ struct TokenAccountCLIContext {
ollama: ollama,
jetbrains: jetbrains,
perplexity: perplexity,
- abacus: abacus)
+ abacus: abacus,
+ mistral: mistral)
}
private func makeCodexSettingsSnapshot(account: ProviderTokenAccount?) ->
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift
new file mode 100644
index 000000000..0bd65cb44
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift
@@ -0,0 +1,101 @@
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+
+private let mistralCookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.mistral]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+public enum MistralCookieImporter {
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["mistral.ai", "admin.mistral.ai", "auth.mistral.ai"]
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public init(cookies: [HTTPCookie], sourceLabel: String) {
+ self.cookies = cookies
+ self.sourceLabel = sourceLabel
+ }
+
+ public var cookieHeader: String {
+ self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
+ }
+
+ /// Extracts the CSRF token from the `csrftoken` cookie for the `X-CSRFTOKEN` header.
+ public var csrfToken: String? {
+ self.cookies.first { $0.name == "csrftoken" }?.value
+ }
+ }
+
+ /// Returns `true` if any cookie name starts with `ory_session_` (the Ory Kratos session cookie).
+ private static func hasSessionCookie(_ cookies: [HTTPCookie]) -> Bool {
+ cookies.contains { $0.name.hasPrefix("ory_session_") }
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection,
+ preferredBrowsers: [Browser] = [.chrome],
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let log: (String) -> Void = { msg in logger?("[mistral-cookie] \(msg)") }
+ let installedBrowsers = preferredBrowsers.isEmpty
+ ? mistralCookieImportOrder.cookieImportCandidates(using: browserDetection)
+ : preferredBrowsers.cookieImportCandidates(using: browserDetection)
+
+ for browserSource in installedBrowsers {
+ do {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let sources = try Self.cookieClient.records(
+ matching: query,
+ in: browserSource,
+ logger: log)
+ for source in sources where !source.records.isEmpty {
+ let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
+ if !httpCookies.isEmpty {
+ guard Self.hasSessionCookie(httpCookies) else {
+ log("Skipping \(source.label) cookies: missing ory_session_* cookie")
+ continue
+ }
+ log("Found \(httpCookies.count) Mistral cookies in \(source.label)")
+ return SessionInfo(cookies: httpCookies, sourceLabel: source.label)
+ }
+ }
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
+ }
+ }
+
+ throw MistralCookieImportError.noCookies
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection,
+ preferredBrowsers: [Browser] = [.chrome],
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ do {
+ _ = try self.importSession(
+ browserDetection: browserDetection,
+ preferredBrowsers: preferredBrowsers,
+ logger: logger)
+ return true
+ } catch {
+ return false
+ }
+ }
+}
+
+enum MistralCookieImportError: LocalizedError {
+ case noCookies
+
+ var errorDescription: String? {
+ switch self {
+ case .noCookies:
+ "No Mistral session cookies found in browsers."
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift
new file mode 100644
index 000000000..1c8ab4ae4
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+public enum MistralUsageError: LocalizedError, Sendable {
+ case missingCookie
+ case invalidCredentials
+ case apiError(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCookie:
+ "No Mistral session cookies found in browsers."
+ case .invalidCredentials:
+ "Mistral session expired or invalid (HTTP 401/403)."
+ case let .apiError(detail):
+ "Mistral API error: \(detail)"
+ case let .parseFailed(detail):
+ "Failed to parse Mistral billing response: \(detail)"
+ }
+ }
+}
+
+enum MistralSettingsError: LocalizedError {
+ case missingCookie
+ case invalidCookie
+
+ var errorDescription: String? {
+ switch self {
+ case .missingCookie:
+ "No Mistral session cookies found in browsers."
+ case .invalidCookie:
+ "Mistral cookie header is invalid or missing ory_session cookie."
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift
new file mode 100644
index 000000000..cdd81427e
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift
@@ -0,0 +1,124 @@
+import Foundation
+
+// MARK: - API Response Models
+
+/// Top-level response from `GET https://admin.mistral.ai/api/billing/v2/usage`.
+struct MistralBillingResponse: Codable {
+ let completion: MistralModelUsageCategory?
+ let ocr: MistralModelUsageCategory?
+ let connectors: MistralModelUsageCategory?
+ let librariesApi: MistralLibrariesUsageCategory?
+ let fineTuning: MistralFineTuningCategory?
+ let audio: MistralModelUsageCategory?
+ let vibeUsage: Double?
+ let date: String?
+ let previousMonth: String?
+ let nextMonth: String?
+ let startDate: String?
+ let endDate: String?
+ let currency: String?
+ let currencySymbol: String?
+ let prices: [MistralPrice]?
+
+ enum CodingKeys: String, CodingKey {
+ case completion, ocr, connectors, audio, date, currency, prices
+ case librariesApi = "libraries_api"
+ case fineTuning = "fine_tuning"
+ case vibeUsage = "vibe_usage"
+ case previousMonth = "previous_month"
+ case nextMonth = "next_month"
+ case startDate = "start_date"
+ case endDate = "end_date"
+ case currencySymbol = "currency_symbol"
+ }
+}
+
+struct MistralModelUsageCategory: Codable {
+ let models: [String: MistralModelUsageData]?
+}
+
+struct MistralLibrariesUsageCategory: Codable {
+ let pages: MistralModelUsageCategory?
+ let tokens: MistralModelUsageCategory?
+}
+
+struct MistralFineTuningCategory: Codable {
+ let training: [String: MistralModelUsageData]?
+ let storage: [String: MistralModelUsageData]?
+}
+
+struct MistralModelUsageData: Codable {
+ let input: [MistralUsageEntry]?
+ let output: [MistralUsageEntry]?
+ let cached: [MistralUsageEntry]?
+}
+
+struct MistralUsageEntry: Codable {
+ let usageType: String?
+ let eventType: String?
+ let billingMetric: String?
+ let billingDisplayName: String?
+ let billingGroup: String?
+ let timestamp: String?
+ let value: Int?
+ let valuePaid: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case timestamp, value
+ case usageType = "usage_type"
+ case eventType = "event_type"
+ case billingMetric = "billing_metric"
+ case billingDisplayName = "billing_display_name"
+ case billingGroup = "billing_group"
+ case valuePaid = "value_paid"
+ }
+}
+
+struct MistralPrice: Codable {
+ let eventType: String?
+ let billingMetric: String?
+ let billingGroup: String?
+ let price: String?
+
+ enum CodingKeys: String, CodingKey {
+ case price
+ case eventType = "event_type"
+ case billingMetric = "billing_metric"
+ case billingGroup = "billing_group"
+ }
+}
+
+// MARK: - Intermediate Snapshot
+
+public struct MistralUsageSnapshot: Sendable {
+ public let totalCost: Double
+ public let currency: String
+ public let currencySymbol: String
+ public let totalInputTokens: Int
+ public let totalOutputTokens: Int
+ public let totalCachedTokens: Int
+ public let modelCount: Int
+ public let startDate: Date?
+ public let endDate: Date?
+ public let updatedAt: Date
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let resetDate = self.endDate.map { Calendar.current.date(byAdding: .second, value: 1, to: $0) ?? $0 }
+ let costDescription = if self.totalCost > 0 {
+ "\(self.currencySymbol)\(String(format: "%.4f", self.totalCost)) this month"
+ } else {
+ "No usage this month"
+ }
+ let primary = RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: resetDate,
+ resetDescription: costDescription)
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: nil)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift
new file mode 100644
index 000000000..914a2c5e8
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift
@@ -0,0 +1,121 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum MistralProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .mistral,
+ metadata: ProviderMetadata(
+ id: .mistral,
+ displayName: "Mistral",
+ sessionLabel: "Monthly",
+ weeklyLabel: "",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Mistral usage",
+ cliName: "mistral",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder,
+ dashboardURL: "https://admin.mistral.ai/organization/usage",
+ statusPageURL: nil,
+ statusLinkURL: "https://status.mistral.ai"),
+ branding: ProviderBranding(
+ iconStyle: .mistral,
+ iconResourceName: "ProviderIcon-mistral",
+ color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Mistral cost summary is not yet supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MistralWebFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "mistral",
+ aliases: ["mistral-ai"],
+ versionDetector: nil))
+ }
+}
+
+struct MistralWebFetchStrategy: ProviderFetchStrategy {
+ let id: String = "mistral.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ guard context.settings?.mistral?.cookieSource != .off else { return false }
+ return true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let cookieSource = context.settings?.mistral?.cookieSource ?? .auto
+ do {
+ let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: true)
+ let snapshot = try await MistralUsageFetcher.fetchUsage(
+ cookieHeader: cookieHeader,
+ csrfToken: csrfToken,
+ timeout: context.webTimeout)
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: "web")
+ } catch MistralUsageError.invalidCredentials where cookieSource != .manual {
+ #if os(macOS)
+ CookieHeaderCache.clear(provider: .mistral)
+ let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: false)
+ let snapshot = try await MistralUsageFetcher.fetchUsage(
+ cookieHeader: cookieHeader,
+ csrfToken: csrfToken,
+ timeout: context.webTimeout)
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: "web")
+ #else
+ throw MistralUsageError.invalidCredentials
+ #endif
+ }
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveCookieHeader(
+ context: ProviderFetchContext,
+ allowCached: Bool) throws -> (cookieHeader: String, csrfToken: String?)
+ {
+ if let settings = context.settings?.mistral, settings.cookieSource == .manual {
+ if let header = CookieHeaderNormalizer.normalize(settings.manualCookieHeader) {
+ let pairs = CookieHeaderNormalizer.pairs(from: header)
+ let hasSessionCookie = pairs.contains { $0.name.hasPrefix("ory_session_") }
+ if hasSessionCookie {
+ let csrfToken = pairs.first { $0.name == "csrftoken" }?.value
+ return (header, csrfToken)
+ }
+ }
+ throw MistralSettingsError.invalidCookie
+ }
+
+ #if os(macOS)
+ if allowCached,
+ let cached = CookieHeaderCache.load(provider: .mistral),
+ !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ let pairs = CookieHeaderNormalizer.pairs(from: cached.cookieHeader)
+ let csrfToken = pairs.first { $0.name == "csrftoken" }?.value
+ return (cached.cookieHeader, csrfToken)
+ }
+ let session = try MistralCookieImporter.importSession(browserDetection: context.browserDetection)
+ CookieHeaderCache.store(
+ provider: .mistral,
+ cookieHeader: session.cookieHeader,
+ sourceLabel: session.sourceLabel)
+ return (session.cookieHeader, session.csrfToken)
+ #else
+ throw MistralSettingsError.missingCookie
+ #endif
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift
new file mode 100644
index 000000000..1e4925444
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift
@@ -0,0 +1,197 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public enum MistralUsageFetcher {
+ private static let baseURL = URL(string: "https://admin.mistral.ai")!
+
+ public static func fetchUsage(
+ cookieHeader: String,
+ csrfToken: String?,
+ timeout: TimeInterval = 15) async throws -> MistralUsageSnapshot
+ {
+ let now = Date()
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ let month = calendar.component(.month, from: now)
+ let year = calendar.component(.year, from: now)
+
+ let usagePath = self.baseURL.appendingPathComponent("/api/billing/v2/usage")
+ var components = URLComponents(url: usagePath, resolvingAgainstBaseURL: false)!
+ components.queryItems = [
+ URLQueryItem(name: "month", value: "\(month)"),
+ URLQueryItem(name: "year", value: "\(year)"),
+ ]
+ guard let url = components.url else {
+ throw MistralUsageError.apiError("Failed to construct URL")
+ }
+
+ var request = URLRequest(url: url, timeoutInterval: timeout)
+ request.setValue("*/*", forHTTPHeaderField: "Accept")
+ request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+ request.setValue("https://admin.mistral.ai/organization/usage", forHTTPHeaderField: "Referer")
+ request.setValue("https://admin.mistral.ai", forHTTPHeaderField: "Origin")
+ if let csrfToken {
+ request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN")
+ }
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw MistralUsageError.apiError("Invalid response type")
+ }
+
+ switch httpResponse.statusCode {
+ case 200:
+ break
+ case 401, 403:
+ throw MistralUsageError.invalidCredentials
+ default:
+ let body = String(data: data.prefix(200), encoding: .utf8) ?? ""
+ throw MistralUsageError.apiError("HTTP \(httpResponse.statusCode): \(body)")
+ }
+
+ return try Self.parseResponse(data: data, updatedAt: now)
+ }
+
+ static func parseResponse(data: Data, updatedAt: Date) throws -> MistralUsageSnapshot {
+ let decoder = JSONDecoder()
+ let billing: MistralBillingResponse
+ do {
+ billing = try decoder.decode(MistralBillingResponse.self, from: data)
+ } catch {
+ throw MistralUsageError.parseFailed(error.localizedDescription)
+ }
+
+ let prices = Self.buildPriceIndex(billing.prices ?? [])
+ var totalCost: Double = 0
+ var totalInput = 0
+ var totalOutput = 0
+ var totalCached = 0
+ var modelCount = 0
+
+ // Aggregate completion tokens
+ if let models = billing.completion?.models {
+ for (_, modelData) in models {
+ modelCount += 1
+ let (input, output, cached, cost) = Self.aggregateModel(modelData, prices: prices)
+ totalInput += input
+ totalOutput += output
+ totalCached += cached
+ totalCost += cost
+ }
+ }
+
+ // Aggregate OCR, connectors, audio if present
+ for category in [billing.ocr, billing.connectors, billing.audio] {
+ if let models = category?.models {
+ for (_, modelData) in models {
+ let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices)
+ totalCost += cost
+ }
+ }
+ }
+
+ // Aggregate libraries_api (pages + tokens)
+ for category in [billing.librariesApi?.pages, billing.librariesApi?.tokens] {
+ if let models = category?.models {
+ for (_, modelData) in models {
+ let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices)
+ totalCost += cost
+ }
+ }
+ }
+
+ // Aggregate fine_tuning (training + storage)
+ for models in [billing.fineTuning?.training, billing.fineTuning?.storage] {
+ if let models {
+ for (_, modelData) in models {
+ let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices)
+ totalCost += cost
+ }
+ }
+ }
+
+ let currency = billing.currency ?? "EUR"
+ let currencySymbol = billing.currencySymbol ?? "€"
+
+ let startDate = billing.startDate.flatMap { Self.parseDate($0) }
+ let endDate = billing.endDate.flatMap { Self.parseDate($0) }
+
+ return MistralUsageSnapshot(
+ totalCost: totalCost,
+ currency: currency,
+ currencySymbol: currencySymbol,
+ totalInputTokens: totalInput,
+ totalOutputTokens: totalOutput,
+ totalCachedTokens: totalCached,
+ modelCount: modelCount,
+ startDate: startDate,
+ endDate: endDate,
+ updatedAt: updatedAt)
+ }
+
+ // MARK: - Private Helpers
+
+ private static func buildPriceIndex(_ prices: [MistralPrice]) -> [String: Double] {
+ var index: [String: Double] = [:]
+ for price in prices {
+ guard let metric = price.billingMetric,
+ let group = price.billingGroup,
+ let priceStr = price.price,
+ let value = Double(priceStr)
+ else { continue }
+ let key = "\(metric)::\(group)"
+ index[key] = value
+ }
+ return index
+ }
+
+ private static func aggregateModel(
+ _ data: MistralModelUsageData,
+ prices: [String: Double]) -> (input: Int, output: Int, cached: Int, cost: Double)
+ {
+ var totalInput = 0
+ var totalOutput = 0
+ var totalCached = 0
+ var totalCost: Double = 0
+
+ for entry in data.input ?? [] {
+ let tokens = entry.valuePaid ?? entry.value ?? 0
+ totalInput += tokens
+ if let metric = entry.billingMetric, let group = entry.billingGroup {
+ let pricePerToken = prices["\(metric)::\(group)"] ?? 0
+ totalCost += Double(tokens) * pricePerToken
+ }
+ }
+
+ for entry in data.output ?? [] {
+ let tokens = entry.valuePaid ?? entry.value ?? 0
+ totalOutput += tokens
+ if let metric = entry.billingMetric, let group = entry.billingGroup {
+ let pricePerToken = prices["\(metric)::\(group)"] ?? 0
+ totalCost += Double(tokens) * pricePerToken
+ }
+ }
+
+ for entry in data.cached ?? [] {
+ let tokens = entry.valuePaid ?? entry.value ?? 0
+ totalCached += tokens
+ if let metric = entry.billingMetric, let group = entry.billingGroup {
+ let pricePerToken = prices["\(metric)::\(group)"] ?? 0
+ totalCost += Double(tokens) * pricePerToken
+ }
+ }
+
+ return (totalInput, totalOutput, totalCached, totalCost)
+ }
+
+ private static func parseDate(_ string: String) -> Date? {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: string) { return date }
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: string)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 6fb994efc..d98204b8e 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -79,6 +79,7 @@ public enum ProviderDescriptorRegistry {
.warp: WarpProviderDescriptor.descriptor,
.perplexity: PerplexityProviderDescriptor.descriptor,
.abacus: AbacusProviderDescriptor.descriptor,
+ .mistral: MistralProviderDescriptor.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 63aa6221c..2692c4920 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -21,7 +21,8 @@ public struct ProviderSettingsSnapshot: Sendable {
ollama: OllamaProviderSettings? = nil,
jetbrains: JetBrainsProviderSettings? = nil,
perplexity: PerplexityProviderSettings? = nil,
- abacus: AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot
+ abacus: AbacusProviderSettings? = nil,
+ mistral: MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
debugMenuEnabled: debugMenuEnabled,
@@ -43,7 +44,8 @@ public struct ProviderSettingsSnapshot: Sendable {
ollama: ollama,
jetbrains: jetbrains,
perplexity: perplexity,
- abacus: abacus)
+ abacus: abacus,
+ mistral: mistral)
}
public struct CodexProviderSettings: Sendable {
@@ -244,6 +246,16 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct MistralProviderSettings: 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?
@@ -264,6 +276,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let jetbrains: JetBrainsProviderSettings?
public let perplexity: PerplexityProviderSettings?
public let abacus: AbacusProviderSettings?
+ public let mistral: MistralProviderSettings?
public var jetbrainsIDEBasePath: String? {
self.jetbrains?.ideBasePath
@@ -289,7 +302,8 @@ public struct ProviderSettingsSnapshot: Sendable {
ollama: OllamaProviderSettings?,
jetbrains: JetBrainsProviderSettings? = nil,
perplexity: PerplexityProviderSettings? = nil,
- abacus: AbacusProviderSettings? = nil)
+ abacus: AbacusProviderSettings? = nil,
+ mistral: MistralProviderSettings? = nil)
{
self.debugMenuEnabled = debugMenuEnabled
self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive
@@ -311,6 +325,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.jetbrains = jetbrains
self.perplexity = perplexity
self.abacus = abacus
+ self.mistral = mistral
}
}
@@ -333,6 +348,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings)
case abacus(ProviderSettingsSnapshot.AbacusProviderSettings)
+ case mistral(ProviderSettingsSnapshot.MistralProviderSettings)
}
public struct ProviderSettingsSnapshotBuilder: Sendable {
@@ -356,6 +372,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings?
public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings?
public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings?
+ public var mistral: ProviderSettingsSnapshot.MistralProviderSettings?
public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) {
self.debugMenuEnabled = debugMenuEnabled
@@ -382,6 +399,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .jetbrains(value): self.jetbrains = value
case let .perplexity(value): self.perplexity = value
case let .abacus(value): self.abacus = value
+ case let .mistral(value): self.mistral = value
}
}
@@ -406,6 +424,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
ollama: self.ollama,
jetbrains: self.jetbrains,
perplexity: self.perplexity,
- abacus: self.abacus)
+ abacus: self.abacus,
+ mistral: self.mistral)
}
}
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 83dade054..22493fa0f 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -29,6 +29,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case openrouter
case perplexity
case abacus
+ case mistral
}
// swiftformat:enable sortDeclarations
@@ -60,6 +61,7 @@ public enum IconStyle: Sendable, CaseIterable {
case openrouter
case perplexity
case abacus
+ case mistral
case combined
}
diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
index a13d28a80..893be0d5a 100644
--- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
+++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
@@ -72,5 +72,12 @@ extension TokenAccountSupportCatalog {
injection: .cookieHeader,
requiresManualCookieSource: true,
cookieName: nil),
+ .mistral: TokenAccountSupport(
+ title: "Session tokens",
+ subtitle: "Store multiple Mistral Cookie headers.",
+ placeholder: "Cookie: …",
+ injection: .cookieHeader,
+ requiresManualCookieSource: true,
+ cookieName: nil),
]
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 17ddf1dba..69cc02db6 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -235,7 +235,8 @@ enum CostUsageScanner {
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot,
.minimax, .kilo, .kiro, .kimi,
- .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus:
+ .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus,
+ .mistral:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index f5f5187e3..05692abab 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -77,6 +77,7 @@ enum ProviderChoice: String, AppEnum {
case .warp: return nil // Warp not yet supported in widgets
case .perplexity: return nil // Perplexity not yet supported in widgets
case .abacus: return nil // Abacus AI not yet supported in widgets
+ case .mistral: return nil // Mistral not yet supported in widgets
}
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 4e03801b3..00c791aa8 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -283,6 +283,7 @@ private struct ProviderSwitchChip: View {
case .warp: "Warp"
case .perplexity: "Pplx"
case .abacus: "Abacus"
+ case .mistral: "Mistral"
}
}
}
@@ -644,6 +645,8 @@ enum WidgetColors {
Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal
case .abacus:
Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255)
+ case .mistral:
+ Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange
}
}
}
diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift
index b4e3b7522..baa64bc34 100644
--- a/Tests/CodexBarTests/MenuCardModelTests.swift
+++ b/Tests/CodexBarTests/MenuCardModelTests.swift
@@ -707,4 +707,50 @@ struct MenuCardModelTests {
#expect(primary.resetText == nil)
#expect(primary.detailText == "10/100 credits")
}
+
+ @Test
+ func `mistral model surfaces monthly cost as primary detail text`() throws {
+ let now = Date()
+ let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60)
+ let identity = ProviderIdentitySnapshot(
+ providerID: .mistral,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: resetsAt,
+ resetDescription: "€1.2345 this month"),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: now,
+ identity: identity)
+ let metadata = try #require(ProviderDefaults.metadata[.mistral])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .mistral,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: true,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ let primary = try #require(model.metrics.first)
+ #expect(primary.detailText == "€1.2345 this month")
+ #expect(primary.resetText?.hasPrefix("Resets") == true)
+ }
}
diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift
new file mode 100644
index 000000000..ec1d6dd84
--- /dev/null
+++ b/Tests/CodexBarTests/MistralUsageParserTests.swift
@@ -0,0 +1,222 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct MistralUsageParserTests {
+ // swiftlint:disable line_length
+
+ private static let novemberResponseJSON = """
+ {"completion":{"models":{"mistral-large-latest::mistral-large-2411":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"input","timestamp":"2025-11-14","value":11121,"value_paid":11121}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"output","timestamp":"2025-11-14","value":1115,"value_paid":1115}]},"mistral-small-latest::mistral-small-2506":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-14","value":20,"value_paid":20},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-24","value":100,"value_paid":100}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-14","value":500,"value_paid":500},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-24","value":2482,"value_paid":2482}]}}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2025-11-01T00:00:00Z","previous_month":"2025-10","next_month":"2025-12","start_date":"2025-11-01T00:00:00Z","end_date":"2025-11-30T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"input","price":"0.0000017000"},{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"output","price":"0.0000051000"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"input","price":"8.50E-8"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"output","price":"2.550E-7"}]}
+ """
+
+ private static let emptyResponseJSON = """
+ {"completion":{"models":{}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2026-02-01T00:00:00Z","previous_month":"2026-01","next_month":"2026-03","start_date":"2026-02-01T00:00:00Z","end_date":"2026-02-28T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[]}
+ """
+
+ // swiftlint:enable line_length
+
+ @Test
+ func `parses response with usage data and computes token totals`() throws {
+ let data = try #require(Self.novemberResponseJSON.data(using: .utf8))
+ let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+
+ // mistral-large input: 11121, mistral-small input: 20+100=120
+ #expect(snapshot.totalInputTokens == 11121 + 120)
+ // mistral-large output: 1115, mistral-small output: 500+2482=2982
+ #expect(snapshot.totalOutputTokens == 1115 + 2982)
+ #expect(snapshot.totalCachedTokens == 0)
+ #expect(snapshot.modelCount == 2)
+ #expect(snapshot.currency == "EUR")
+ #expect(snapshot.currencySymbol == "€")
+ }
+
+ @Test
+ func `computes cost from tokens and prices`() throws {
+ let data = try #require(Self.novemberResponseJSON.data(using: .utf8))
+ let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+
+ // mistral-large-2411 input: 11121 * 0.0000017 = 0.0189057
+ // mistral-large-2411 output: 1115 * 0.0000051 = 0.0056865
+ // mistral-small-2506 input: 120 * 0.000000085 = 0.0000102
+ // mistral-small-2506 output: 2982 * 0.000000255 = 0.00076041
+ let expectedCost = 0.0189057 + 0.0056865 + 0.0000102 + 0.00076041
+ #expect(abs(snapshot.totalCost - expectedCost) < 0.0001)
+ #expect(snapshot.totalCost > 0)
+ }
+
+ @Test
+ func `parses empty response with no usage`() throws {
+ let data = try #require(Self.emptyResponseJSON.data(using: .utf8))
+ let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+
+ #expect(snapshot.totalInputTokens == 0)
+ #expect(snapshot.totalOutputTokens == 0)
+ #expect(snapshot.totalCost == 0)
+ #expect(snapshot.modelCount == 0)
+ #expect(snapshot.currency == "EUR")
+ }
+
+ @Test
+ func `parses dates from response`() throws {
+ let data = try #require(Self.novemberResponseJSON.data(using: .utf8))
+ let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+
+ #expect(snapshot.startDate != nil)
+ #expect(snapshot.endDate != nil)
+
+ let calendar = Calendar.current
+ if let start = snapshot.startDate {
+ #expect(calendar.component(.month, from: start) == 11)
+ #expect(calendar.component(.year, from: start) == 2025)
+ }
+ }
+
+ @Test
+ func `throws parseFailed for invalid JSON`() {
+ let data = Data("not json".utf8)
+ #expect(throws: MistralUsageError.self) {
+ try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+ }
+ }
+}
+
+struct MistralUsageSnapshotConversionTests {
+ @Test
+ func `converts cost into primary resetDescription so it surfaces as detail text`() {
+ let snapshot = MistralUsageSnapshot(
+ totalCost: 1.2345,
+ currency: "EUR",
+ currencySymbol: "€",
+ totalInputTokens: 10000,
+ totalOutputTokens: 5000,
+ totalCachedTokens: 0,
+ modelCount: 2,
+ startDate: nil,
+ endDate: Date(),
+ updatedAt: Date())
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary != nil)
+ #expect(usage.primary?.usedPercent == 0)
+ #expect(usage.primary?.resetDescription?.contains("€1.2345") == true)
+ // providerCost is intentionally nil: the menu card's providerCostSection requires
+ // limit > 0 to render a bar, and Mistral is pay-as-you-go with no quota. The cost
+ // is surfaced via primary.resetDescription (rendered as detail text in the card).
+ #expect(usage.providerCost == nil)
+ }
+
+ @Test
+ func `converts zero cost with no-usage description`() {
+ let snapshot = MistralUsageSnapshot(
+ totalCost: 0,
+ currency: "USD",
+ currencySymbol: "$",
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalCachedTokens: 0,
+ modelCount: 0,
+ startDate: nil,
+ endDate: nil,
+ updatedAt: Date())
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.resetDescription == "No usage this month")
+ }
+}
+
+struct MistralStrategyTests {
+ 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
+ }
+ }
+
+ private func makeContext(
+ sourceMode: ProviderSourceMode = .auto,
+ settings: ProviderSettingsSnapshot? = nil,
+ env: [String: String] = [:]) -> ProviderFetchContext
+ {
+ let browserDetection = BrowserDetection(cacheTTL: 0)
+ return ProviderFetchContext(
+ runtime: .cli,
+ sourceMode: sourceMode,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: env,
+ settings: settings,
+ fetcher: UsageFetcher(environment: env),
+ claudeFetcher: StubClaudeFetcher(),
+ browserDetection: browserDetection)
+ }
+
+ @Test
+ func `strategy is unavailable when cookie source is off`() async {
+ let settings = ProviderSettingsSnapshot.make(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: .off,
+ manualCookieHeader: nil))
+ let context = self.makeContext(settings: settings)
+ let strategy = MistralWebFetchStrategy()
+
+ let available = await strategy.isAvailable(context)
+ #expect(available == false)
+ }
+
+ @Test
+ func `strategy is available when cookie source is auto`() async {
+ let settings = ProviderSettingsSnapshot.make(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(settings: settings)
+ let strategy = MistralWebFetchStrategy()
+
+ let available = await strategy.isAvailable(context)
+ #expect(available == true)
+ }
+
+ @Test
+ func `strategy is available when cookie source is manual`() async {
+ let settings = ProviderSettingsSnapshot.make(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: .manual,
+ manualCookieHeader: "ory_session_x=abc; csrftoken=xyz"))
+ let context = self.makeContext(settings: settings)
+ let strategy = MistralWebFetchStrategy()
+
+ let available = await strategy.isAvailable(context)
+ #expect(available == true)
+ }
+
+ @Test
+ func `strategy never falls back (single strategy provider)`() {
+ let strategy = MistralWebFetchStrategy()
+ let context = self.makeContext()
+ let shouldFallback = strategy.shouldFallback(
+ on: MistralUsageError.invalidCredentials,
+ context: context)
+ #expect(shouldFallback == false)
+ }
+
+ @Test
+ func `descriptor metadata is correct`() {
+ let descriptor = MistralProviderDescriptor.descriptor
+ #expect(descriptor.id == .mistral)
+ #expect(descriptor.metadata.displayName == "Mistral")
+ #expect(descriptor.metadata.cliName == "mistral")
+ #expect(descriptor.metadata.defaultEnabled == false)
+ #expect(descriptor.cli.name == "mistral")
+ #expect(descriptor.fetchPlan.sourceModes == [.auto, .web])
+ #expect(descriptor.branding.iconResourceName == "ProviderIcon-mistral")
+ }
+}
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index 0a50a5166..5273d249b 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -912,6 +912,7 @@ struct SettingsStoreTests {
.openrouter,
.perplexity,
.abacus,
+ .mistral,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/docs/providers.md b/docs/providers.md
index a82898e78..0fe26b2cf 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, Abacus AI)."
+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, Abacus AI, Mistral)."
read_when:
- Adding or modifying provider fetch/parsing
- Adjusting provider labels, toggles, or metadata
@@ -40,6 +40,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Ollama | Web settings page via browser cookies (`web`). |
| OpenRouter | API token (config, overrides env) → credits API (`api`). |
| Abacus AI | Browser cookies → compute points + billing API (`web`). |
+| Mistral | Console billing API via Ory Kratos session cookies (`web`). |
## Codex
- Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -192,4 +193,15 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Status: none yet.
- Details: `docs/abacus.md`.
+## Mistral
+- Session cookie (`ory_session_*`) from browser auto-import or manual `Cookie:` header.
+- CSRF token (`csrftoken` cookie) sent as `X-CSRFTOKEN` header.
+- Domain: `admin.mistral.ai`.
+- Billing endpoint: `GET https://admin.mistral.ai/api/billing/v2/usage?month=&year=`.
+- Returns monthly token usage per model (completion, OCR, audio, connectors, fine-tuning) with pricing.
+- Cost computed client-side from token counts × per-model prices included in the response.
+- Currency from response (typically EUR).
+- Resets at end of calendar month.
+- Status: `https://status.mistral.ai` (link only, no auto-polling).
+
See also: `docs/provider.md` for architecture notes.