From 973e873dca0dafd1510075e773f93ff7c6093ef8 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Thu, 26 Mar 2026 16:28:11 +0100 Subject: [PATCH] feat: add Mistral AI as a new provider Add support for tracking Mistral AI platform usage via the admin console billing API. This is a cookie-based provider (similar to OpenCode/Cursor) that fetches monthly token consumption and computes costs from the response pricing data. Authentication uses Ory Kratos session cookies (ory_session_*) with CSRF token support. Supports both automatic browser cookie import (Chrome by default) and manual cookie header entry. Closes steipete/CodexBar#220 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../MistralProviderImplementation.swift | 106 +++++++++ .../Mistral/MistralSettingsStore.swift | 64 +++++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-mistral.svg | 1 + Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 13 +- .../Mistral/MistralCookieImporter.swift | 101 ++++++++ .../Providers/Mistral/MistralErrors.swift | 35 +++ .../Providers/Mistral/MistralModels.swift | 131 +++++++++++ .../Mistral/MistralProviderDescriptor.swift | 121 ++++++++++ .../Mistral/MistralUsageFetcher.swift | 193 +++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 27 ++- .../CodexBarCore/Providers/Providers.swift | 2 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../MistralUsageParserTests.swift | 222 ++++++++++++++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 1 + docs/providers.md | 14 +- 20 files changed, 1032 insertions(+), 9 deletions(-) create mode 100644 Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-mistral.svg create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralModels.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift create mode 100644 Tests/CodexBarTests/MistralUsageParserTests.swift 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 @@ +Mistral 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.