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 9cb99850b..0f7e6bfd5 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 .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 47efc5b63..cd43fdc56 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1231,7 +1231,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, .mistral:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index 937b37aa0..449ab568c 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -177,6 +177,13 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
+ 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
}
@@ -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,
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
@@ -212,7 +220,8 @@ struct TokenAccountCLIContext {
augment: augment,
amp: amp,
ollama: ollama,
- jetbrains: jetbrains)
+ jetbrains: jetbrains,
+ mistral: mistral)
}
func environment(
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..e392568dd
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift
@@ -0,0 +1,131 @@
+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)
+ let providerCost = ProviderCostSnapshot(
+ used: self.totalCost,
+ limit: 0,
+ currencyCode: self.currency,
+ period: "Monthly",
+ resetsAt: resetDate,
+ updatedAt: self.updatedAt)
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ providerCost: providerCost,
+ 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..7548743bb
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift
@@ -0,0 +1,193 @@
+import Foundation
+
+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()
+ let calendar = Calendar.current
+ 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 0596617b7..4bcda9eb5 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,
+ .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 c5d9af4f7..aa350c873 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,
+ mistral: MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
debugMenuEnabled: debugMenuEnabled,
@@ -37,7 +38,8 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: augment,
amp: amp,
ollama: ollama,
- jetbrains: jetbrains)
+ jetbrains: jetbrains,
+ mistral: mistral)
}
public struct CodexProviderSettings: Sendable {
@@ -209,6 +211,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?
@@ -226,6 +238,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let amp: AmpProviderSettings?
public let ollama: OllamaProviderSettings?
public let jetbrains: JetBrainsProviderSettings?
+ public let mistral: MistralProviderSettings?
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,
+ mistral: MistralProviderSettings? = 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.mistral = mistral
}
}
@@ -286,6 +301,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case amp(ProviderSettingsSnapshot.AmpProviderSettings)
case ollama(ProviderSettingsSnapshot.OllamaProviderSettings)
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
+ case mistral(ProviderSettingsSnapshot.MistralProviderSettings)
}
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 mistral: ProviderSettingsSnapshot.MistralProviderSettings?
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 .mistral(value): self.mistral = value
}
}
@@ -350,6 +368,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
augment: self.augment,
amp: self.amp,
ollama: self.ollama,
- jetbrains: self.jetbrains)
+ jetbrains: self.jetbrains,
+ mistral: self.mistral)
}
}
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 039f7a3f3..8d2b4a5d8 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 mistral
}
// swiftformat:enable sortDeclarations
@@ -54,6 +55,7 @@ public enum IconStyle: Sendable, CaseIterable {
case synthetic
case warp
case openrouter
+ case mistral
case combined
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 0e6767e15..e7b73c66d 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, .mistral:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index c828a2695..608607fd6 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 .mistral: return nil // Mistral not yet supported in widgets
}
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 3b0dd2d27..49704845d 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 .mistral: "Mistral"
}
}
}
@@ -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 .mistral:
+ Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange
}
}
}
diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift
new file mode 100644
index 000000000..d894a6ad9
--- /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 to UsageSnapshot with provider cost`() {
+ 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)
+ #expect(usage.providerCost != nil)
+ #expect(usage.providerCost?.used == 1.2345)
+ #expect(usage.providerCost?.currencyCode == "EUR")
+ #expect(usage.providerCost?.period == "Monthly")
+ }
+
+ @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 a7cfb81b1..7615882da 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -767,6 +767,7 @@ struct SettingsStoreTests {
.synthetic,
.warp,
.openrouter,
+ .mistral,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/docs/providers.md b/docs/providers.md
index 63f3aeaa0..fd95affe1 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, Mistral)."
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`). |
+| Mistral | Console billing API via Ory Kratos session cookies (`web`). |
## Codex
- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -182,4 +183,15 @@ 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`.
+## 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.