diff --git a/README.md b/README.md
index 3f685d6fc..3f8f50183 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# CodexBar 🎚️ - May your tokens never run out.
-Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, and Perplexity limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
+Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, and Mistral limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
@@ -47,6 +47,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- [Amp](docs/amp.md) — Browser cookie-based authentication with Amp Free usage tracking.
- [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking.
- [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers.
+- [Mistral](docs/providers.md#mistral) — AI Studio billing via cookies with public API-key fallback for model access.
- Open to new providers: [provider authoring guide](docs/provider.md).
## Icon & Screenshot
diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 9da4f9e04..f27cd970f 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -86,6 +86,8 @@ struct UsageMenuCardView: View {
let title: String
let percentUsed: Double
let spendLine: String
+ let showsProgress: Bool
+ let trailingText: String?
}
let provider: UsageProvider
@@ -330,17 +332,21 @@ private struct ProviderCostContent: View {
Text(self.section.title)
.font(.body)
.fontWeight(.medium)
- UsageProgressBar(
- percent: self.section.percentUsed,
- tint: self.progressColor,
- accessibilityLabel: "Extra usage spent")
+ if self.section.showsProgress {
+ UsageProgressBar(
+ percent: self.section.percentUsed,
+ tint: self.progressColor,
+ accessibilityLabel: "Extra usage spent")
+ }
HStack(alignment: .firstTextBaseline) {
Text(self.section.spendLine)
.font(.footnote)
- Spacer()
- Text(String(format: "%.0f%% used", min(100, max(0, self.section.percentUsed))))
- .font(.footnote)
- .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ if let trailingText = self.section.trailingText {
+ Spacer()
+ Text(trailingText)
+ .font(.footnote)
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ }
}
}
}
@@ -785,6 +791,18 @@ extension UsageMenuCardView.Model {
return notes
}
+ if input.provider == .mistral {
+ guard let snapshot = input.snapshot else { return [] }
+ if let summary = snapshot.mistralUsage {
+ return Self.mistralUsageNotes(summary: summary, snapshot: snapshot)
+ }
+ guard snapshot.primary == nil, snapshot.secondary == nil else { return [] }
+ return [
+ "Connected to the public Mistral API",
+ "Usage and billing totals are currently only available in Mistral AI Studio",
+ ]
+ }
+
guard input.provider == .openrouter,
let openRouter = input.snapshot?.openRouterUsage
else {
@@ -819,6 +837,9 @@ extension UsageMenuCardView.Model {
account: AccountInfo,
metadata: ProviderMetadata) -> String?
{
+ if provider == .mistral, snapshot?.mistralUsage != nil {
+ return nil
+ }
if provider == .kilo {
guard let pass = self.kiloLoginPass(snapshot: snapshot) else {
return nil
@@ -941,7 +962,9 @@ extension UsageMenuCardView.Model {
let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit)
let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit)
let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot)
- if let primary = snapshot.primary {
+ if let primary = snapshot.primary,
+ Self.shouldRenderPrimaryMetric(provider: input.provider, snapshot: snapshot)
+ {
var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil
var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now)
if input.provider == .openrouter,
@@ -1267,29 +1290,98 @@ extension UsageMenuCardView.Model {
cost: ProviderCostSnapshot?) -> ProviderCostSection?
{
guard let cost else { return nil }
- guard cost.limit > 0 else { return nil }
let used: String
- let limit: String
let title: String
+ let trailingText: String?
+ let showsProgress: Bool
- if cost.currencyCode == "Quota" {
+ if cost.limit <= 0 {
+ guard provider == .mistral else { return nil }
+ title = "Billing"
+ used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
+ trailingText = nil
+ showsProgress = false
+ } else if cost.currencyCode == "Quota" {
title = "Quota usage"
used = String(format: "%.0f", cost.used)
- limit = String(format: "%.0f", cost.limit)
+ let limit = String(format: "%.0f", cost.limit)
+ trailingText = "\(String(format: "%.0f%% used", Self.clamped((cost.used / cost.limit) * 100)))"
+ showsProgress = true
+ let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
+ let periodLabel = cost.period ?? "This month"
+
+ return ProviderCostSection(
+ title: title,
+ percentUsed: percentUsed,
+ spendLine: "\(periodLabel): \(used) / \(limit)",
+ showsProgress: showsProgress,
+ trailingText: trailingText)
} else {
title = "Extra usage"
used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
- limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
+ let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
+ trailingText = "\(String(format: "%.0f%% used", Self.clamped((cost.used / cost.limit) * 100)))"
+ showsProgress = true
+ let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
+ let periodLabel = cost.period ?? "This month"
+
+ return ProviderCostSection(
+ title: title,
+ percentUsed: percentUsed,
+ spendLine: "\(periodLabel): \(used) / \(limit)",
+ showsProgress: showsProgress,
+ trailingText: trailingText)
}
- let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
let periodLabel = cost.period ?? "This month"
return ProviderCostSection(
title: title,
- percentUsed: percentUsed,
- spendLine: "\(periodLabel): \(used) / \(limit)")
+ percentUsed: 0,
+ spendLine: "\(periodLabel): \(used)",
+ showsProgress: false,
+ trailingText: nil)
+ }
+
+ private static func shouldRenderPrimaryMetric(provider: UsageProvider, snapshot: UsageSnapshot) -> Bool {
+ guard provider == .mistral, snapshot.mistralUsage != nil, let primary = snapshot.primary else {
+ return true
+ }
+ guard snapshot.secondary == nil else { return true }
+ guard let providerCost = snapshot.providerCost, providerCost.limit <= 0 else { return true }
+ return primary.usedPercent > 0
+ }
+
+ private static func mistralUsageNotes(summary: MistralUsageSummarySnapshot, snapshot: UsageSnapshot) -> [String] {
+ var notes: [String] = []
+ switch summary.sourceKind {
+ case .web:
+ if let tokenLine = summary.tokenSummaryLine, !tokenLine.isEmpty {
+ notes.append(tokenLine)
+ }
+ if let workspaceLine = summary.workspaceLine, !workspaceLine.isEmpty {
+ notes.append(workspaceLine)
+ }
+ if let modelsLine = summary.modelsLine, !modelsLine.isEmpty {
+ notes.append(modelsLine)
+ }
+ case .api:
+ notes.append("API fallback active")
+ if let modelsLine = summary.modelsLine, !modelsLine.isEmpty {
+ notes.append(modelsLine)
+ }
+ if let preview = summary.previewModelNames, !preview.isEmpty {
+ notes.append(preview)
+ }
+ if let workspaceLine = summary.workspaceLine, !workspaceLine.isEmpty {
+ notes.append(workspaceLine)
+ }
+ if snapshot.primary == nil, snapshot.secondary == nil {
+ notes.append("Sign into Mistral AI Studio in Chrome to unlock billing totals automatically")
+ }
+ }
+ return notes
}
private static func clamped(_ value: Double) -> Double {
diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift
index edcc1d0dc..b0f3f9e16 100644
--- a/Sources/CodexBar/PreferencesProviderDetailView.swift
+++ b/Sources/CodexBar/PreferencesProviderDetailView.swift
@@ -537,21 +537,25 @@ private struct ProviderMetricInlineCostRow: View {
.frame(width: self.labelWidth, alignment: .leading)
VStack(alignment: .leading, spacing: 4) {
- UsageProgressBar(
- percent: self.section.percentUsed,
- tint: self.progressColor,
- accessibilityLabel: "Usage used")
- .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity)
+ if self.section.showsProgress {
+ UsageProgressBar(
+ percent: self.section.percentUsed,
+ tint: self.progressColor,
+ accessibilityLabel: "Usage used")
+ .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity)
+ }
HStack(alignment: .firstTextBaseline, spacing: 8) {
- Text(String(format: "%.0f%% used", self.section.percentUsed))
- .font(.footnote)
- .foregroundStyle(.secondary)
- .monospacedDigit()
- Spacer(minLength: 8)
Text(self.section.spendLine)
.font(.footnote)
.foregroundStyle(.secondary)
+ if let trailingText = self.section.trailingText {
+ Spacer(minLength: 8)
+ Text(trailingText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ }
}
}
diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift
index 414f41c55..58422ef57 100644
--- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift
+++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift
@@ -133,6 +133,21 @@ struct ProviderSettingsPickerRowView: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
+
+ let actions = self.picker.actions.filter { $0.isVisible?() ?? true }
+ if !actions.isEmpty {
+ HStack(spacing: 10) {
+ ForEach(actions) { action in
+ Button(action.title) {
+ Task { @MainActor in
+ await action.perform()
+ }
+ }
+ .applyProviderSettingsButtonStyle(action.style)
+ .controlSize(.small)
+ }
+ }
+ }
}
.disabled(!isEnabled)
.onChange(of: self.picker.binding.wrappedValue) { _, selection in
diff --git a/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift
new file mode 100644
index 000000000..23da5d4ef
--- /dev/null
+++ b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift
@@ -0,0 +1,182 @@
+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 { context in
+ context.store.sourceLabel(for: context.provider)
+ }
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.mistralAPIToken
+ _ = settings.mistralCookieSource
+ _ = settings.mistralManualCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .mistral(context.settings.mistralSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func isAvailable(context: ProviderAvailabilityContext) -> Bool {
+ if MistralSettingsReader.apiKey(environment: context.environment) != nil {
+ return true
+ }
+ if !context.settings.mistralAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return true
+ }
+
+ switch context.settings.mistralCookieSource {
+ case .auto:
+ return true
+ case .manual:
+ return !context.settings.mistralManualCookieHeader
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .isEmpty
+ case .off:
+ return false
+ }
+ }
+
+ @MainActor
+ func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? {
+ context.settings.mistralCookieSource == .off ? "api" : "auto"
+ }
+
+ @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 options = ProviderCookieSourceUI.options(
+ allowsOff: true,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+ let subtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.mistralCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Recommended. Sign into Mistral AI Studio in Chrome, open the usage page once, and CodexBar will pick up billing automatically.",
+ manual: "Advanced. Paste a full Cookie header captured from Mistral AI Studio.",
+ off: "API only. Uses your API key for model access while billing stays in Mistral AI Studio.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "mistral-cookie-source",
+ title: "Usage source",
+ subtitle: "Recommended. Sign into Mistral AI Studio in Chrome, open the usage page once, and CodexBar will pick up billing automatically.",
+ dynamicSubtitle: subtitle,
+ binding: cookieBinding,
+ options: options,
+ 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)"
+ },
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "mistral-open-ai-studio-picker",
+ title: "Open AI Studio",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://console.mistral.ai/usage") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ]),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ let apiKeyField = ProviderSettingsFieldDescriptor(
+ id: "mistral-api-key",
+ title: "API key (Optional)",
+ subtitle: "Used for public API access, model discovery, and API-only fallback when web billing data is unavailable.",
+ kind: .secure,
+ placeholder: "Paste API key…",
+ binding: context.stringBinding(\.mistralAPIToken),
+ actions: [],
+ isVisible: nil,
+ onActivate: { context.settings.ensureMistralAPITokenLoaded() })
+
+ let cookieField = ProviderSettingsFieldDescriptor(
+ id: "mistral-cookie-header",
+ title: "Cookie header (Advanced)",
+ subtitle: "Paste the Cookie header from Mistral AI Studio. It should include an ory_session_* cookie and usually csrftoken.",
+ kind: .secure,
+ placeholder: "ory_session_…=…; csrftoken=…",
+ binding: context.stringBinding(\.mistralManualCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "mistral-open-admin-usage",
+ title: "Open Usage Page",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://console.mistral.ai/usage") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.mistralCookieSource == .manual },
+ onActivate: { context.settings.ensureMistralCookieLoaded() })
+
+ return [
+ apiKeyField,
+ cookieField,
+ ]
+ }
+
+ @MainActor
+ func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) {
+ guard let summary = context.snapshot?.mistralUsage, summary.sourceKind == .web else { return }
+
+ if let spendLine = self.mistralSpendLine(summary) {
+ entries.append(.text(spendLine, .primary))
+ }
+
+ if let tokenLine = summary.tokenSummaryLine, !tokenLine.isEmpty {
+ entries.append(.text(tokenLine, .secondary))
+ }
+
+ if summary.modelCount > 0 {
+ let label = summary.modelCount == 1 ? "1 billed model" : "\(summary.modelCount) billed models"
+ entries.append(.text(label, .secondary))
+ }
+ if let workspaceLine = summary.workspaceLine, !workspaceLine.isEmpty {
+ entries.append(.text(workspaceLine, .secondary))
+ }
+ }
+
+ private func mistralSpendLine(_ summary: MistralUsageSummarySnapshot) -> String? {
+ guard let totalCost = summary.totalCost else { return nil }
+ let amount: String
+ if let currencyCode = summary.currencyCode {
+ amount = UsageFormatter.currencyString(totalCost, currencyCode: currencyCode)
+ } else if let currencySymbol = summary.currencySymbol {
+ amount = currencySymbol + String(format: "%.4f", totalCost)
+ } else {
+ amount = String(format: "%.4f", totalCost)
+ }
+ if let period = summary.billingPeriodLabel {
+ return "\(amount) in \(period)"
+ }
+ return amount
+ }
+}
diff --git a/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift
new file mode 100644
index 000000000..30d33e638
--- /dev/null
+++ b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift
@@ -0,0 +1,48 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var mistralAPIToken: String {
+ get { self.configSnapshot.providerConfig(for: .mistral)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .mistral) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .mistral, field: "apiKey", value: newValue)
+ }
+ }
+
+ var mistralManualCookieHeader: 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 ensureMistralAPITokenLoaded() {}
+
+ func ensureMistralCookieLoaded() {}
+}
+
+extension SettingsStore {
+ func mistralSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot
+ .MistralProviderSettings {
+ return ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: self.mistralCookieSource,
+ manualCookieHeader: self.mistralManualCookieHeader,
+ prefersAPIInAuto: tokenOverride?.provider == .mistral)
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 6fb94b479..c996010ce 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -35,6 +35,7 @@ enum ProviderImplementationRegistry {
case .ollama: OllamaProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
+ case .mistral: MistralProviderImplementation()
case .warp: WarpProviderImplementation()
case .perplexity: PerplexityProviderImplementation()
}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift
index d5a85b8f7..97f55107f 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift
@@ -115,6 +115,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable {
let isEnabled: (() -> Bool)?
let onChange: ((_ selection: String) async -> Void)?
let trailingText: (() -> String?)?
+ let actions: [ProviderSettingsActionDescriptor]
init(
id: String,
@@ -126,7 +127,8 @@ struct ProviderSettingsPickerDescriptor: Identifiable {
isVisible: (() -> Bool)?,
isEnabled: (() -> Bool)? = nil,
onChange: ((_ selection: String) async -> Void)?,
- trailingText: (() -> String?)? = nil)
+ trailingText: (() -> String?)? = nil,
+ actions: [ProviderSettingsActionDescriptor] = [])
{
self.id = id
self.title = title
@@ -138,6 +140,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable {
self.isEnabled = isEnabled
self.onChange = onChange
self.trailingText = trailingText
+ self.actions = actions
}
}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-mistral.svg b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg
new file mode 100644
index 000000000..d02541d88
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg
@@ -0,0 +1,3 @@
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 18225a92f..c292de275 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -738,6 +738,7 @@ extension UsageStore {
.kimi: "Kimi debug log not yet implemented",
.kimik2: "Kimi K2 debug log not yet implemented",
.jetbrains: "JetBrains AI debug log not yet implemented",
+ .mistral: "Mistral debug log not yet implemented",
]
let buildText = {
switch provider {
@@ -810,6 +811,11 @@ extension UsageStore {
let hasAny = resolution != nil
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
+ case .mistral:
+ let resolution = ProviderTokenResolver.mistralResolution()
+ let hasAny = resolution != nil
+ let source = resolution?.source.rawValue ?? "none"
+ return "MISTRAL_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
.kimik2, .jetbrains, .perplexity:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index d52302847..b64ab029a 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -184,6 +184,14 @@ struct TokenAccountCLIContext {
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
+ case .mistral:
+ let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
+ let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
+ return self.makeSnapshot(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: cookieSource,
+ manualCookieHeader: cookieHeader,
+ prefersAPIInAuto: account != nil))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
return nil
}
@@ -204,7 +212,8 @@ struct TokenAccountCLIContext {
amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil,
ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
- perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil,
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
@@ -221,7 +230,8 @@ struct TokenAccountCLIContext {
amp: amp,
ollama: ollama,
jetbrains: jetbrains,
- perplexity: perplexity)
+ perplexity: perplexity,
+ mistral: mistral)
}
func environment(
diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
index 6620ae879..bbd225010 100644
--- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
+++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
@@ -31,6 +31,8 @@ public enum ProviderConfigEnvironment {
}
case .openrouter:
env[OpenRouterSettingsReader.envKey] = apiKey
+ case .mistral:
+ env[MistralSettingsReader.apiKeyEnvironmentKey] = apiKey
default:
break
}
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index 0f2a6b0f9..7ac97e961 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -37,6 +37,7 @@ public enum LogCategories {
public static let minimaxCookieStore = "minimax-cookie-store"
public static let minimaxUsage = "minimax-usage"
public static let minimaxWeb = "minimax-web"
+ public static let mistralUsage = "mistral-usage"
public static let notifications = "notifications"
public static let openAIWeb = "openai-web"
public static let openAIWebview = "openai-webview"
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralCookieHeader.swift b/Sources/CodexBarCore/Providers/Mistral/MistralCookieHeader.swift
new file mode 100644
index 000000000..a8c927f16
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralCookieHeader.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+public struct MistralCookieOverride: Sendable, Equatable {
+ public let cookieHeader: String
+ public let csrfToken: String?
+ public let sessionCookieName: String
+
+ public init(cookieHeader: String, csrfToken: String?, sessionCookieName: String) {
+ self.cookieHeader = cookieHeader
+ self.csrfToken = csrfToken
+ self.sessionCookieName = sessionCookieName
+ }
+}
+
+public enum MistralCookieHeader {
+ public static func resolveCookieOverride(context: ProviderFetchContext) -> MistralCookieOverride? {
+ if let settings = context.settings?.mistral,
+ settings.cookieSource == .manual,
+ let manual = settings.manualCookieHeader,
+ !manual.isEmpty
+ {
+ return self.override(from: manual, explicitCSRFToken: MistralSettingsReader.csrfToken(environment: context.env))
+ }
+
+ if let envCookie = MistralSettingsReader.cookieHeader(environment: context.env) {
+ return self.override(from: envCookie, explicitCSRFToken: MistralSettingsReader.csrfToken(environment: context.env))
+ }
+
+ return nil
+ }
+
+ public static func override(from raw: String?, explicitCSRFToken: String? = nil) -> MistralCookieOverride? {
+ guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil }
+ return self.override(fromNormalizedHeader: normalized, explicitCSRFToken: explicitCSRFToken)
+ }
+
+ public static func sessionCookie(from cookies: [HTTPCookie]) -> MistralCookieOverride? {
+ let header = cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
+ return self.override(fromNormalizedHeader: header)
+ }
+
+ public static func override(
+ fromNormalizedHeader normalizedHeader: String,
+ explicitCSRFToken: String? = nil) -> MistralCookieOverride?
+ {
+ let pairs = CookieHeaderNormalizer.pairs(from: normalizedHeader)
+ guard let sessionPair = pairs.first(where: { self.isSessionCookieName($0.name) }) else {
+ return nil
+ }
+
+ let csrfToken = explicitCSRFToken
+ ?? pairs.first(where: { $0.name.caseInsensitiveCompare("csrftoken") == .orderedSame })?.value
+ return MistralCookieOverride(
+ cookieHeader: normalizedHeader,
+ csrfToken: csrfToken,
+ sessionCookieName: sessionPair.name)
+ }
+
+ public static func isSessionCookieName(_ name: String) -> Bool {
+ name.hasPrefix("ory_session_")
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift
new file mode 100644
index 000000000..038060c8d
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift
@@ -0,0 +1,97 @@
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+
+private let mistralCookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.mistral]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+public enum MistralCookieImporter {
+ private static let log = CodexBarLog.logger(LogCategories.mistralUsage)
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["console.mistral.ai", "admin.mistral.ai", "auth.mistral.ai", "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 cookieOverride: MistralCookieOverride? {
+ MistralCookieHeader.sessionCookie(from: self.cookies)
+ }
+
+ public var cookieHeader: String? {
+ self.cookieOverride?.cookieHeader
+ }
+
+ public var csrfToken: String? {
+ self.cookieOverride?.csrfToken
+ }
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let log: (String) -> Void = { message in self.emit(message, logger: logger) }
+
+ for browserSource in mistralCookieImportOrder.cookieImportCandidates(using: browserDetection) {
+ 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)
+ guard !httpCookies.isEmpty else { continue }
+ let session = SessionInfo(cookies: httpCookies, sourceLabel: source.label)
+ guard session.cookieOverride != nil else {
+ log("Skipping \(source.label) cookies: missing ory_session_* cookie")
+ continue
+ }
+ log("Found \(httpCookies.count) Mistral cookies in \(source.label)")
+ return session
+ }
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
+ }
+ }
+
+ throw MistralCookieImportError.noCookies
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ do {
+ _ = try self.importSession(browserDetection: browserDetection, logger: logger)
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ private static func emit(_ message: String, logger: ((String) -> Void)?) {
+ logger?("[mistral-cookie] \(message)")
+ self.log.debug(message)
+ }
+}
+
+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/MistralDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralDescriptor.swift
new file mode 100644
index 000000000..3fbe9f284
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralDescriptor.swift
@@ -0,0 +1,81 @@
+import CodexBarMacroSupport
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+#endif
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum MistralProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ #if os(macOS)
+ let browserOrder = ProviderBrowserCookieDefaults.defaultImportOrder
+ #else
+ let browserOrder: BrowserCookieImportOrder? = nil
+ #endif
+
+ return ProviderDescriptor(
+ id: .mistral,
+ metadata: ProviderMetadata(
+ id: .mistral,
+ displayName: "Mistral",
+ sessionLabel: "Requests",
+ weeklyLabel: "Tokens",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "Mistral billing totals come from AI Studio cookies when available.",
+ toggleTitle: "Show Mistral usage",
+ cliName: "mistral",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: browserOrder,
+ dashboardURL: "https://console.mistral.ai/usage",
+ statusPageURL: nil,
+ statusLinkURL: "https://status.mistral.ai"),
+ branding: ProviderBranding(
+ iconStyle: .mistral,
+ iconResourceName: "ProviderIcon-mistral",
+ color: ProviderColor(red: 112 / 255, green: 86 / 255, blue: 255 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: {
+ "Mistral billing totals come from the web dashboard cookie flow; the public API remains available as a model-access fallback."
+ }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),
+ cli: ProviderCLIConfig(
+ name: "mistral",
+ aliases: ["mistralai", "mistral-ai"],
+ versionDetector: nil))
+ }
+
+ private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] {
+ switch context.sourceMode {
+ case .web:
+ return [MistralWebFetchStrategy()]
+ case .api:
+ return [MistralAPIFetchStrategy()]
+ case .cli, .oauth:
+ return []
+ case .auto:
+ break
+ }
+
+ if context.settings?.mistral?.cookieSource == .off {
+ return [MistralAPIFetchStrategy()]
+ }
+
+ // Token-account/API-key overrides are account-scoped, while browser cookies are workspace-scoped.
+ // In auto mode, prefer the API strategy first so account switching does not silently return the
+ // browser-logged workspace instead of the selected Mistral account.
+ if context.settings?.mistral?.prefersAPIInAuto == true {
+ return [MistralAPIFetchStrategy(), MistralWebFetchStrategy()]
+ }
+
+ return [MistralWebFetchStrategy(), MistralAPIFetchStrategy()]
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralFetcher.swift
new file mode 100644
index 000000000..72d19980d
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralFetcher.swift
@@ -0,0 +1,703 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public enum MistralFetcher {
+ private static let log = CodexBarLog.logger(LogCategories.mistralUsage)
+ private static let timeout: TimeInterval = 15
+ private static let maxErrorBodyLength = 240
+
+ public static func fetchUsage(
+ apiKey: String,
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ session: URLSession? = nil) async throws -> MistralAPIUsageSnapshot
+ {
+ guard let cleanedKey = MistralSettingsReader.cleaned(apiKey) else {
+ throw MistralUsageError.missingToken
+ }
+
+ let session = session ?? Self.makeSession()
+ let requestURL = self.modelsURL(environment: environment)
+ var request = URLRequest(url: requestURL)
+ request.httpMethod = "GET"
+ request.timeoutInterval = Self.timeout
+ request.setValue("Bearer \(cleanedKey)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ let (data, response) = try await session.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw MistralUsageError.invalidResponse
+ }
+
+ let responseBody = Self.bodySnippet(from: data, maxLength: Self.maxErrorBodyLength)
+ guard (200..<300).contains(httpResponse.statusCode) else {
+ if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
+ throw MistralUsageError.unauthorized
+ }
+ if httpResponse.statusCode == 429 {
+ let retryAfter = Self.retryAfterDate(headers: httpResponse.allHeaderFields)
+ throw MistralUsageError.rateLimited(retryAfter: retryAfter)
+ }
+ throw MistralUsageError.unexpectedStatus(code: httpResponse.statusCode, body: responseBody)
+ }
+
+ let decoded: MistralModelListResponse
+ do {
+ decoded = try Self.parseModelListResponse(data: data)
+ } catch {
+ let snippet = responseBody ?? ""
+ Self.log.error("Failed to decode Mistral model list: \(error.localizedDescription) | body: \(snippet)")
+ throw MistralUsageError.decodeFailed("\(error.localizedDescription) | body: \(snippet)")
+ }
+
+ let rateLimits = MistralRateLimitSnapshot(
+ requests: Self.rateLimitWindow(
+ kind: "requests",
+ headers: httpResponse.allHeaderFields,
+ now: Date()),
+ tokens: Self.rateLimitWindow(
+ kind: "tokens",
+ headers: httpResponse.allHeaderFields,
+ now: Date()),
+ retryAfter: Self.retryAfterDate(headers: httpResponse.allHeaderFields))
+
+ return MistralAPIUsageSnapshot(
+ models: decoded.data,
+ rateLimits: rateLimits.orderedWindows.isEmpty ? nil : rateLimits,
+ updatedAt: Date())
+ }
+
+ public static func fetchBillingUsage(
+ cookieHeader: String,
+ csrfToken: String?,
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ timeout: TimeInterval? = nil,
+ session: URLSession? = nil) async throws -> MistralBillingUsageSnapshot
+ {
+ guard let override = MistralCookieHeader.override(
+ from: cookieHeader,
+ explicitCSRFToken: MistralSettingsReader.cleaned(csrfToken))
+ else {
+ throw MistralUsageError.invalidCookie
+ }
+
+ let requestTimeout = timeout ?? Self.timeout
+ let session = session ?? Self.makeSession(timeout: requestTimeout)
+ let now = Date()
+ var lastError: MistralUsageError?
+
+ for requestURL in self.billingURLs(environment: environment, now: now) {
+ var request = URLRequest(url: requestURL)
+ request.httpMethod = "GET"
+ request.timeoutInterval = requestTimeout
+ request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
+ request.setValue(override.cookieHeader, forHTTPHeaderField: "Cookie")
+ request.setValue(self.referer(for: requestURL), forHTTPHeaderField: "Referer")
+ request.setValue(self.origin(for: requestURL), forHTTPHeaderField: "Origin")
+ if let csrf = MistralSettingsReader.cleaned(csrfToken ?? override.csrfToken) {
+ request.setValue(csrf, forHTTPHeaderField: "X-CSRFTOKEN")
+ }
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ lastError = MistralUsageError.networkError(error.localizedDescription)
+ continue
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw MistralUsageError.invalidResponse
+ }
+
+ let responseBody = Self.bodySnippet(from: data, maxLength: Self.maxErrorBodyLength)
+ switch httpResponse.statusCode {
+ case 200:
+ do {
+ return try Self.parseBillingResponse(data: data, updatedAt: now)
+ } catch let error as MistralUsageError {
+ throw error
+ } catch {
+ let snippet = responseBody ?? ""
+ Self.log.error("Failed to parse Mistral billing response: \(error.localizedDescription) | body: \(snippet)")
+ throw MistralUsageError.parseFailed("\(error.localizedDescription) | body: \(snippet)")
+ }
+ case 401, 403:
+ throw MistralUsageError.invalidCredentials
+ case 404:
+ lastError = MistralUsageError.unexpectedStatus(code: httpResponse.statusCode, body: responseBody)
+ continue
+ default:
+ throw MistralUsageError.unexpectedStatus(code: httpResponse.statusCode, body: responseBody)
+ }
+ }
+
+ throw lastError ?? MistralUsageError.missingCookie
+ }
+
+ private static func makeSession(timeout: TimeInterval? = nil) -> URLSession {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.timeoutIntervalForRequest = timeout ?? Self.timeout
+ configuration.timeoutIntervalForResource = timeout ?? Self.timeout
+ return URLSession(configuration: configuration)
+ }
+
+ private static func modelsURL(environment: [String: String]) -> URL {
+ MistralSettingsReader.apiURL(environment: environment).appendingPathComponent("models")
+ }
+
+ private static func billingURLs(environment: [String: String], now: Date) -> [URL] {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current
+
+ let queryItems = [
+ URLQueryItem(name: "month", value: String(calendar.component(.month, from: now))),
+ URLQueryItem(name: "year", value: String(calendar.component(.year, from: now))),
+ URLQueryItem(name: "by_workspace", value: "true"),
+ ]
+
+ let bases = [
+ MistralSettingsReader.consoleURL(environment: environment),
+ MistralSettingsReader.adminURL(environment: environment),
+ ]
+
+ var urls: [URL] = []
+ var seen = Set()
+ for base in bases {
+ let usageURL = base.appendingPathComponent("api/billing/v2/usage")
+ var components = URLComponents(url: usageURL, resolvingAgainstBaseURL: false)
+ components?.queryItems = queryItems
+ let url = components?.url ?? usageURL
+ if seen.insert(url.absoluteString).inserted {
+ urls.append(url)
+ }
+ }
+ return urls
+ }
+
+ private static func referer(for requestURL: URL) -> String {
+ let host = requestURL.host ?? "console.mistral.ai"
+ if host.hasPrefix("console.") {
+ return "https://\(host)/usage"
+ }
+ return "https://\(host)/organization/usage"
+ }
+
+ private static func origin(for requestURL: URL) -> String {
+ let scheme = requestURL.scheme ?? "https"
+ let host = requestURL.host ?? "console.mistral.ai"
+ return "\(scheme)://\(host)"
+ }
+
+ private static func rateLimitWindow(
+ kind: String,
+ headers: [AnyHashable: Any],
+ now: Date) -> MistralRateLimitWindow?
+ {
+ let normalized = Self.normalizedHeaders(headers)
+ let limit = Self.intValue(
+ in: normalized,
+ keys: Self.limitKeys(kind: kind))
+ let remaining = Self.intValue(
+ in: normalized,
+ keys: Self.remainingKeys(kind: kind))
+ let resetAt = Self.dateValue(
+ in: normalized,
+ keys: Self.resetKeys(kind: kind),
+ now: now)
+
+ guard limit != nil || remaining != nil || resetAt != nil else {
+ return nil
+ }
+
+ let resetDescription: String? = {
+ if let limit, let remaining {
+ return "\(Self.capitalized(kind)): \(remaining)/\(limit)"
+ }
+ if let resetAt {
+ return "\(Self.capitalized(kind)) reset \(Self.formatDate(resetAt))"
+ }
+ if let limit {
+ return "\(Self.capitalized(kind)): limit \(limit)"
+ }
+ if let remaining {
+ return "\(Self.capitalized(kind)): \(remaining) remaining"
+ }
+ return nil
+ }()
+
+ return MistralRateLimitWindow(
+ kind: kind,
+ limit: limit,
+ remaining: remaining,
+ resetsAt: resetAt,
+ resetDescription: resetDescription)
+ }
+
+ private static func retryAfterDate(headers: [AnyHashable: Any]) -> Date? {
+ let normalized = Self.normalizedHeaders(headers)
+ if let date = Self.dateValue(in: normalized, keys: ["retry-after"], now: Date()) {
+ return date
+ }
+ if let seconds = Self.intValue(in: normalized, keys: ["retry-after"]) {
+ return Date(timeIntervalSinceNow: TimeInterval(seconds))
+ }
+ return nil
+ }
+
+ private static func normalizedHeaders(_ headers: [AnyHashable: Any]) -> [String: String] {
+ var normalized: [String: String] = [:]
+ normalized.reserveCapacity(headers.count)
+ for (key, value) in headers {
+ let keyString = String(describing: key).lowercased()
+ let valueString = String(describing: value).trimmingCharacters(in: .whitespacesAndNewlines)
+ normalized[keyString] = valueString
+ }
+ return normalized
+ }
+
+ private static func intValue(in headers: [String: String], keys: [String]) -> Int? {
+ for key in keys {
+ guard let value = headers[key] else { continue }
+ if let int = Int(value) {
+ return int
+ }
+ if let double = Double(value) {
+ return Int(double.rounded())
+ }
+ }
+ return nil
+ }
+
+ private static func dateValue(in headers: [String: String], keys: [String], now: Date) -> Date? {
+ for key in keys {
+ guard let raw = headers[key], !raw.isEmpty else { continue }
+ if let seconds = Int(raw) {
+ return Self.interpretTimestamp(seconds, now: now)
+ }
+ if let double = Double(raw) {
+ return Self.interpretTimestamp(double, now: now)
+ }
+ if let iso = Self.makeISO8601DateFormatter().date(from: raw) {
+ return iso
+ }
+ if let rfc = Self.makeRFC1123DateFormatter().date(from: raw) {
+ return rfc
+ }
+ if let reset = Self.makeHTTPDateFormatter().date(from: raw) {
+ return reset
+ }
+ }
+ return nil
+ }
+
+ private static func limitKeys(kind: String) -> [String] {
+ [
+ "x-ratelimit-limit-\(kind)",
+ "ratelimit-limit-\(kind)",
+ "x-rate-limit-limit-\(kind)",
+ "x-ratelimit-limit",
+ "ratelimit-limit",
+ ]
+ }
+
+ private static func remainingKeys(kind: String) -> [String] {
+ [
+ "x-ratelimit-remaining-\(kind)",
+ "ratelimit-remaining-\(kind)",
+ "x-rate-limit-remaining-\(kind)",
+ "x-ratelimit-remaining",
+ "ratelimit-remaining",
+ ]
+ }
+
+ private static func resetKeys(kind: String) -> [String] {
+ [
+ "x-ratelimit-reset-\(kind)",
+ "ratelimit-reset-\(kind)",
+ "x-rate-limit-reset-\(kind)",
+ "x-ratelimit-reset",
+ "ratelimit-reset",
+ ]
+ }
+
+ private static func interpretTimestamp(_ value: Int, now: Date) -> Date {
+ if value >= 1_000_000_000 {
+ return Date(timeIntervalSince1970: TimeInterval(value))
+ }
+ return now.addingTimeInterval(TimeInterval(value))
+ }
+
+ private static func interpretTimestamp(_ value: Double, now: Date) -> Date {
+ if value >= 1_000_000_000 {
+ return Date(timeIntervalSince1970: value)
+ }
+ return now.addingTimeInterval(value)
+ }
+
+ private static func bodySnippet(from data: Data, maxLength: Int) -> String? {
+ guard !data.isEmpty else { return nil }
+ let text = String(decoding: data, as: UTF8.self)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return nil }
+ return String(text.prefix(maxLength))
+ }
+
+ static func parseModelListResponse(data: Data) throws -> MistralModelListResponse {
+ let json = try JSONSerialization.jsonObject(with: data)
+
+ if let object = json as? [String: Any] {
+ let listObject = object["object"] as? String
+ let items = object["data"] as? [[String: Any]] ?? []
+ return MistralModelListResponse(
+ object: listObject,
+ data: items.map(Self.parseModelCard))
+ }
+
+ if let items = json as? [[String: Any]] {
+ return MistralModelListResponse(
+ object: "list",
+ data: items.map(Self.parseModelCard))
+ }
+
+ throw MistralUsageError.decodeFailed("Unexpected top-level response shape")
+ }
+
+ static func parseBillingResponse(data: Data, updatedAt: Date) throws -> MistralBillingUsageSnapshot {
+ let json = try JSONSerialization.jsonObject(with: data)
+ let root = json as? [String: Any]
+ 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 totalInputTokens = 0
+ var totalOutputTokens = 0
+ var totalCachedTokens = 0
+ var totalCost = 0.0
+ var modelNames: Set = []
+
+ for category in Self.categoryModelMaps(from: billing) {
+ for (modelKey, usageData) in category {
+ let modelTotals = Self.aggregateModelUsage(usageData, prices: prices)
+ totalInputTokens += modelTotals.input
+ totalOutputTokens += modelTotals.output
+ totalCachedTokens += modelTotals.cached
+ totalCost += modelTotals.cost
+
+ let displayName = Self.displayName(for: modelKey, usageData: usageData)
+ if !displayName.isEmpty {
+ modelNames.insert(displayName)
+ }
+ }
+ }
+
+ let workspaces = self.parseWorkspaceSnapshots(root: root, prices: prices)
+
+ return MistralBillingUsageSnapshot(
+ totalCost: totalCost,
+ currency: billing.currency ?? "EUR",
+ currencySymbol: billing.currencySymbol ?? billing.currency ?? "EUR",
+ totalInputTokens: totalInputTokens,
+ totalOutputTokens: totalOutputTokens,
+ totalCachedTokens: totalCachedTokens,
+ modelCount: modelNames.count,
+ startDate: billing.startDate.flatMap(Self.date(fromBillingString:)),
+ endDate: billing.endDate.flatMap(Self.date(fromBillingString:)),
+ workspaces: workspaces,
+ updatedAt: updatedAt)
+ }
+
+ private static func parseModelCard(_ object: [String: Any]) -> MistralModelCard {
+ let capabilitiesObject = object["capabilities"] as? [String: Any] ?? [:]
+ return MistralModelCard(
+ id: Self.string(object["id"]) ?? "unknown-model",
+ object: Self.string(object["object"]),
+ created: Self.int(object["created"]),
+ ownedBy: Self.string(object["owned_by"]),
+ capabilities: MistralModelCapabilities(
+ completionChat: Self.bool(capabilitiesObject["completion_chat"]),
+ completionFim: Self.bool(capabilitiesObject["completion_fim"]),
+ functionCalling: Self.bool(capabilitiesObject["function_calling"]),
+ fineTuning: Self.bool(capabilitiesObject["fine_tuning"]),
+ vision: Self.bool(capabilitiesObject["vision"]),
+ ocr: Self.bool(capabilitiesObject["ocr"]),
+ classification: Self.bool(capabilitiesObject["classification"]),
+ moderation: Self.bool(capabilitiesObject["moderation"]),
+ audio: Self.bool(capabilitiesObject["audio"]),
+ audioTranscription: Self.bool(capabilitiesObject["audio_transcription"])),
+ name: Self.string(object["name"]),
+ description: Self.string(object["description"]),
+ maxContextLength: Self.int(object["max_context_length"]),
+ aliases: Self.stringArray(object["aliases"]),
+ deprecation: Self.date(object["deprecation"]),
+ deprecationReplacementModel: Self.string(object["deprecation_replacement_model"]),
+ defaultModelTemperature: Self.double(object["default_model_temperature"]),
+ type: Self.string(object["type"]),
+ job: Self.string(object["job"]),
+ root: Self.string(object["root"]),
+ archived: Self.optionalBool(object["archived"]))
+ }
+
+ private static func string(_ value: Any?) -> String? {
+ guard let value else { return nil }
+ if let string = value as? String {
+ let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+ return String(describing: value)
+ }
+
+ private static func stringArray(_ value: Any?) -> [String] {
+ guard let array = value as? [Any] else { return [] }
+ return array.compactMap { Self.string($0) }
+ }
+
+ private static func int(_ value: Any?) -> Int? {
+ if let int = value as? Int { return int }
+ if let number = value as? NSNumber { return number.intValue }
+ if let string = value as? String { return Int(string) }
+ return nil
+ }
+
+ private static func double(_ value: Any?) -> Double? {
+ if let double = value as? Double { return double }
+ if let number = value as? NSNumber { return number.doubleValue }
+ if let string = value as? String { return Double(string) }
+ return nil
+ }
+
+ private static func bool(_ value: Any?) -> Bool {
+ self.optionalBool(value) ?? false
+ }
+
+ private static func optionalBool(_ value: Any?) -> Bool? {
+ if let bool = value as? Bool { return bool }
+ if let number = value as? NSNumber { return number.boolValue }
+ if let string = value as? String {
+ switch string.lowercased() {
+ case "true", "1": return true
+ case "false", "0": return false
+ default: return nil
+ }
+ }
+ return nil
+ }
+
+ private static func date(_ value: Any?) -> Date? {
+ guard let raw = Self.string(value) else { return nil }
+ return Self.date(fromBillingString: raw)
+ }
+
+ private static func date(fromBillingString raw: String) -> Date? {
+ let fractional = ISO8601DateFormatter()
+ fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let parsed = fractional.date(from: raw) {
+ return parsed
+ }
+ let plain = ISO8601DateFormatter()
+ plain.formatOptions = [.withInternetDateTime]
+ return plain.date(from: raw)
+ }
+
+ private static func capitalized(_ kind: String) -> String {
+ kind.prefix(1).uppercased() + kind.dropFirst()
+ }
+
+ private static func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.locale = Locale.autoupdatingCurrent
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .short
+ return formatter.string(from: date)
+ }
+
+ private static func makeISO8601DateFormatter() -> ISO8601DateFormatter {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }
+
+ private static func makeRFC1123DateFormatter() -> DateFormatter {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
+ return formatter
+ }
+
+ private static func makeHTTPDateFormatter() -> DateFormatter {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z"
+ return formatter
+ }
+
+ 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 rawPrice = price.price,
+ let amount = Double(rawPrice)
+ else {
+ continue
+ }
+ index["\(metric)|\(group)"] = amount
+ }
+ return index
+ }
+
+ private static func categoryModelMaps(from billing: MistralBillingResponse) -> [[String: MistralModelUsageData]] {
+ var categories: [[String: MistralModelUsageData]] = []
+ if let item = billing.completion?.models, !item.isEmpty { categories.append(item) }
+ if let item = billing.ocr?.models, !item.isEmpty { categories.append(item) }
+ if let item = billing.connectors?.models, !item.isEmpty { categories.append(item) }
+ if let item = billing.audio?.models, !item.isEmpty { categories.append(item) }
+ if let item = billing.librariesApi?.pages?.models, !item.isEmpty { categories.append(item) }
+ if let item = billing.librariesApi?.tokens?.models, !item.isEmpty { categories.append(item) }
+ if let item = billing.fineTuning?.training, !item.isEmpty { categories.append(item) }
+ if let item = billing.fineTuning?.storage, !item.isEmpty { categories.append(item) }
+ return categories
+ }
+
+ private static func aggregateModelUsage(
+ _ data: MistralModelUsageData,
+ prices: [String: Double]) -> (input: Int, output: Int, cached: Int, cost: Double)
+ {
+ let input = Self.aggregateEntries(data.input, billingGroup: "input", prices: prices)
+ let output = Self.aggregateEntries(data.output, billingGroup: "output", prices: prices)
+ let cached = Self.aggregateEntries(data.cached, billingGroup: "cached", prices: prices)
+
+ return (
+ input: input.count,
+ output: output.count,
+ cached: cached.count,
+ cost: input.cost + output.cost + cached.cost)
+ }
+
+ private static func aggregateEntries(
+ _ entries: [MistralUsageEntry]?,
+ billingGroup: String,
+ prices: [String: Double]) -> (count: Int, cost: Double)
+ {
+ guard let entries, !entries.isEmpty else { return (0, 0) }
+ var count = 0
+ var cost = 0.0
+
+ for entry in entries {
+ let value = Int((entry.value ?? 0).rounded())
+ let billableValue: Double = entry.valuePaid ?? entry.value ?? 0
+ count += value
+
+ if let metric = entry.billingMetric,
+ let price = prices["\(metric)|\(billingGroup)"]
+ {
+ cost += billableValue * price
+ }
+ }
+
+ return (count, cost)
+ }
+
+ private static func displayName(for modelKey: String, usageData: MistralModelUsageData) -> String {
+ for entries in [usageData.input, usageData.output, usageData.cached] {
+ if let name = entries?.compactMap({ $0.billingDisplayName }).first(where: { !$0.isEmpty }) {
+ return name
+ }
+ }
+
+ if let separator = modelKey.firstIndex(of: ":") {
+ return String(modelKey[.. [MistralWorkspaceUsageSnapshot]
+ {
+ guard let root else { return [] }
+ let keys = ["workspaces", "by_workspace", "workspace_usage"]
+
+ for key in keys {
+ if let dictionary = root[key] as? [String: Any] {
+ let snapshots = dictionary.compactMap { name, value -> MistralWorkspaceUsageSnapshot? in
+ guard let payload = value as? [String: Any] else { return nil }
+ return self.workspaceSnapshot(name: name, payload: payload, prices: prices)
+ }
+ if !snapshots.isEmpty {
+ return snapshots.sorted { $0.totalCost > $1.totalCost }
+ }
+ }
+
+ if let array = root[key] as? [Any] {
+ let snapshots = array.compactMap { value -> MistralWorkspaceUsageSnapshot? in
+ guard let payload = value as? [String: Any] else { return nil }
+ let workspaceName = self.string(payload["name"])
+ ?? self.string(payload["workspace_name"])
+ ?? self.string(payload["slug"])
+ ?? self.string(payload["id"])
+ guard let workspaceName, !workspaceName.isEmpty else { return nil }
+ return self.workspaceSnapshot(name: workspaceName, payload: payload, prices: prices)
+ }
+ if !snapshots.isEmpty {
+ return snapshots.sorted { $0.totalCost > $1.totalCost }
+ }
+ }
+ }
+
+ return []
+ }
+
+ private static func workspaceSnapshot(
+ name: String,
+ payload: [String: Any],
+ prices: [String: Double]) -> MistralWorkspaceUsageSnapshot?
+ {
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
+ let billing = try? JSONDecoder().decode(MistralBillingResponse.self, from: data)
+ else {
+ return nil
+ }
+
+ var totalInputTokens = 0
+ var totalOutputTokens = 0
+ var totalCachedTokens = 0
+ var totalCost = 0.0
+ var modelNames: Set = []
+
+ for category in self.categoryModelMaps(from: billing) {
+ for (modelKey, usageData) in category {
+ let modelTotals = self.aggregateModelUsage(usageData, prices: prices)
+ totalInputTokens += modelTotals.input
+ totalOutputTokens += modelTotals.output
+ totalCachedTokens += modelTotals.cached
+ totalCost += modelTotals.cost
+
+ let displayName = self.displayName(for: modelKey, usageData: usageData)
+ if !displayName.isEmpty {
+ modelNames.insert(displayName)
+ }
+ }
+ }
+
+ guard totalInputTokens > 0 || totalOutputTokens > 0 || totalCachedTokens > 0 || totalCost > 0 else {
+ return nil
+ }
+
+ return MistralWorkspaceUsageSnapshot(
+ name: name,
+ totalCost: totalCost,
+ totalInputTokens: totalInputTokens,
+ totalOutputTokens: totalOutputTokens,
+ totalCachedTokens: totalCachedTokens,
+ modelCount: modelNames.count)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift
new file mode 100644
index 000000000..9c7017c3b
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift
@@ -0,0 +1,780 @@
+import Foundation
+
+public struct MistralModelListResponse: Decodable, Sendable {
+ public let object: String?
+ public let data: [MistralModelCard]
+
+ public init(object: String?, data: [MistralModelCard]) {
+ self.object = object
+ self.data = data
+ }
+
+ public init(from decoder: Decoder) throws {
+ if let keyed = try? decoder.container(keyedBy: CodingKeys.self) {
+ self.object = try keyed.decodeIfPresent(String.self, forKey: .object)
+ self.data = try keyed.decode([MistralModelCard].self, forKey: .data)
+ return
+ }
+
+ var unkeyed = try decoder.unkeyedContainer()
+ var models: [MistralModelCard] = []
+ while !unkeyed.isAtEnd {
+ models.append(try unkeyed.decode(MistralModelCard.self))
+ }
+ self.object = "list"
+ self.data = models
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case object
+ case data
+ }
+}
+
+public struct MistralModelCard: Decodable, Sendable, Identifiable, Equatable {
+ public let id: String
+ public let object: String?
+ public let created: Int?
+ public let ownedBy: String?
+ public let capabilities: MistralModelCapabilities
+ public let name: String?
+ public let description: String?
+ public let maxContextLength: Int?
+ public let aliases: [String]
+ public let deprecation: Date?
+ public let deprecationReplacementModel: String?
+ public let defaultModelTemperature: Double?
+ public let type: String?
+ public let job: String?
+ public let root: String?
+ public let archived: Bool?
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case object
+ case created
+ case ownedBy = "owned_by"
+ case capabilities
+ case name
+ case description
+ case maxContextLength = "max_context_length"
+ case aliases
+ case deprecation
+ case deprecationReplacementModel = "deprecation_replacement_model"
+ case defaultModelTemperature = "default_model_temperature"
+ case type
+ case job
+ case root
+ case archived
+ }
+
+ public init(
+ id: String,
+ object: String?,
+ created: Int?,
+ ownedBy: String?,
+ capabilities: MistralModelCapabilities,
+ name: String?,
+ description: String?,
+ maxContextLength: Int?,
+ aliases: [String],
+ deprecation: Date?,
+ deprecationReplacementModel: String?,
+ defaultModelTemperature: Double?,
+ type: String?,
+ job: String?,
+ root: String?,
+ archived: Bool?)
+ {
+ self.id = id
+ self.object = object
+ self.created = created
+ self.ownedBy = ownedBy
+ self.capabilities = capabilities
+ self.name = name
+ self.description = description
+ self.maxContextLength = maxContextLength
+ self.aliases = aliases
+ self.deprecation = deprecation
+ self.deprecationReplacementModel = deprecationReplacementModel
+ self.defaultModelTemperature = defaultModelTemperature
+ self.type = type
+ self.job = job
+ self.root = root
+ self.archived = archived
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.id = try container.decode(String.self, forKey: .id)
+ self.object = try container.decodeIfPresent(String.self, forKey: .object)
+ self.created = try container.decodeIfPresent(Int.self, forKey: .created)
+ self.ownedBy = try container.decodeIfPresent(String.self, forKey: .ownedBy)
+ self.capabilities = try container.decodeIfPresent(MistralModelCapabilities.self, forKey: .capabilities)
+ ?? MistralModelCapabilities()
+ self.name = try container.decodeIfPresent(String.self, forKey: .name)
+ self.description = try container.decodeIfPresent(String.self, forKey: .description)
+ self.maxContextLength = try container.decodeIfPresent(Int.self, forKey: .maxContextLength)
+ self.aliases = try container.decodeIfPresent([String].self, forKey: .aliases) ?? []
+ self.deprecation = try Self.decodeDate(container: container, key: .deprecation)
+ self.deprecationReplacementModel = try container.decodeIfPresent(String.self, forKey: .deprecationReplacementModel)
+ self.defaultModelTemperature = try container.decodeIfPresent(Double.self, forKey: .defaultModelTemperature)
+ self.type = try container.decodeIfPresent(String.self, forKey: .type)
+ self.job = try container.decodeIfPresent(String.self, forKey: .job)
+ self.root = try container.decodeIfPresent(String.self, forKey: .root)
+ self.archived = try container.decodeIfPresent(Bool.self, forKey: .archived)
+ }
+
+ public var displayName: String {
+ if let name = self.name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
+ return name
+ }
+ if let root = self.root?.trimmingCharacters(in: .whitespacesAndNewlines), !root.isEmpty {
+ return root
+ }
+ return self.id
+ }
+
+ public var workspaceOwner: String? {
+ guard let ownedBy = self.ownedBy?.trimmingCharacters(in: .whitespacesAndNewlines), !ownedBy.isEmpty else {
+ return nil
+ }
+ if ownedBy == "mistralai" {
+ return nil
+ }
+ return ownedBy
+ }
+
+ private static func decodeDate(
+ container: KeyedDecodingContainer,
+ key: CodingKeys) throws -> Date?
+ {
+ guard let raw = try container.decodeIfPresent(String.self, forKey: key) else {
+ return nil
+ }
+ let fractional = ISO8601DateFormatter()
+ fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let parsed = fractional.date(from: raw) {
+ return parsed
+ }
+ let plain = ISO8601DateFormatter()
+ plain.formatOptions = [.withInternetDateTime]
+ return plain.date(from: raw)
+ }
+}
+
+public struct MistralModelCapabilities: Decodable, Sendable, Equatable {
+ public let completionChat: Bool
+ public let completionFim: Bool
+ public let functionCalling: Bool
+ public let fineTuning: Bool
+ public let vision: Bool
+ public let ocr: Bool
+ public let classification: Bool
+ public let moderation: Bool
+ public let audio: Bool
+ public let audioTranscription: Bool
+
+ private enum CodingKeys: String, CodingKey {
+ case completionChat = "completion_chat"
+ case completionFim = "completion_fim"
+ case functionCalling = "function_calling"
+ case fineTuning = "fine_tuning"
+ case vision
+ case ocr
+ case classification
+ case moderation
+ case audio
+ case audioTranscription = "audio_transcription"
+ }
+
+ public init(
+ completionChat: Bool = false,
+ completionFim: Bool = false,
+ functionCalling: Bool = false,
+ fineTuning: Bool = false,
+ vision: Bool = false,
+ ocr: Bool = false,
+ classification: Bool = false,
+ moderation: Bool = false,
+ audio: Bool = false,
+ audioTranscription: Bool = false)
+ {
+ self.completionChat = completionChat
+ self.completionFim = completionFim
+ self.functionCalling = functionCalling
+ self.fineTuning = fineTuning
+ self.vision = vision
+ self.ocr = ocr
+ self.classification = classification
+ self.moderation = moderation
+ self.audio = audio
+ self.audioTranscription = audioTranscription
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.completionChat = try container.decodeIfPresent(Bool.self, forKey: .completionChat) ?? false
+ self.completionFim = try container.decodeIfPresent(Bool.self, forKey: .completionFim) ?? false
+ self.functionCalling = try container.decodeIfPresent(Bool.self, forKey: .functionCalling) ?? false
+ self.fineTuning = try container.decodeIfPresent(Bool.self, forKey: .fineTuning) ?? false
+ self.vision = try container.decodeIfPresent(Bool.self, forKey: .vision) ?? false
+ self.ocr = try container.decodeIfPresent(Bool.self, forKey: .ocr) ?? false
+ self.classification = try container.decodeIfPresent(Bool.self, forKey: .classification) ?? false
+ self.moderation = try container.decodeIfPresent(Bool.self, forKey: .moderation) ?? false
+ self.audio = try container.decodeIfPresent(Bool.self, forKey: .audio) ?? false
+ self.audioTranscription = try container.decodeIfPresent(Bool.self, forKey: .audioTranscription) ?? false
+ }
+}
+
+public struct MistralRateLimitWindow: Codable, Sendable, Equatable {
+ public let kind: String
+ public let limit: Int?
+ public let remaining: Int?
+ public let resetsAt: Date?
+ public let resetDescription: String?
+
+ public init(
+ kind: String,
+ limit: Int?,
+ remaining: Int?,
+ resetsAt: Date?,
+ resetDescription: String?)
+ {
+ self.kind = kind
+ self.limit = limit
+ self.remaining = remaining
+ self.resetsAt = resetsAt
+ self.resetDescription = resetDescription
+ }
+
+ public var usedPercent: Double? {
+ guard let limit, limit > 0, let remaining else { return nil }
+ let used = max(0, limit - remaining)
+ return min(100, max(0, (Double(used) / Double(limit)) * 100))
+ }
+
+ public func asRateWindow() -> RateWindow? {
+ guard let usedPercent else { return nil }
+ return RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: self.resetsAt,
+ resetDescription: self.resetDescription)
+ }
+}
+
+public struct MistralRateLimitSnapshot: Codable, Sendable, Equatable {
+ public let requests: MistralRateLimitWindow?
+ public let tokens: MistralRateLimitWindow?
+ public let retryAfter: Date?
+
+ public init(requests: MistralRateLimitWindow?, tokens: MistralRateLimitWindow?, retryAfter: Date?) {
+ self.requests = requests
+ self.tokens = tokens
+ self.retryAfter = retryAfter
+ }
+
+ public var orderedWindows: [MistralRateLimitWindow] {
+ [self.requests, self.tokens].compactMap(\.self)
+ }
+}
+
+public struct MistralBillingResponse: Codable, Sendable, Equatable {
+ public let completion: MistralModelUsageCategory?
+ public let ocr: MistralModelUsageCategory?
+ public let connectors: MistralModelUsageCategory?
+ public let librariesApi: MistralLibrariesUsageCategory?
+ public let fineTuning: MistralFineTuningCategory?
+ public let audio: MistralModelUsageCategory?
+ public let vibeUsage: Double?
+ public let date: String?
+ public let previousMonth: String?
+ public let nextMonth: String?
+ public let startDate: String?
+ public let endDate: String?
+ public let currency: String?
+ public let currencySymbol: String?
+ public let prices: [MistralPrice]?
+
+ enum CodingKeys: String, CodingKey {
+ case completion
+ case ocr
+ case connectors
+ case librariesApi = "libraries_api"
+ case fineTuning = "fine_tuning"
+ case audio
+ case vibeUsage = "vibe_usage"
+ case date
+ case previousMonth = "previous_month"
+ case nextMonth = "next_month"
+ case startDate = "start_date"
+ case endDate = "end_date"
+ case currency
+ case currencySymbol = "currency_symbol"
+ case prices
+ }
+}
+
+public struct MistralModelUsageCategory: Codable, Sendable, Equatable {
+ public let models: [String: MistralModelUsageData]?
+
+ public init(models: [String: MistralModelUsageData]?) {
+ self.models = models
+ }
+}
+
+public struct MistralLibrariesUsageCategory: Codable, Sendable, Equatable {
+ public let pages: MistralModelUsageCategory?
+ public let tokens: MistralModelUsageCategory?
+
+ public init(pages: MistralModelUsageCategory?, tokens: MistralModelUsageCategory?) {
+ self.pages = pages
+ self.tokens = tokens
+ }
+}
+
+public struct MistralFineTuningCategory: Codable, Sendable, Equatable {
+ public let training: [String: MistralModelUsageData]?
+ public let storage: [String: MistralModelUsageData]?
+
+ public init(training: [String: MistralModelUsageData]?, storage: [String: MistralModelUsageData]?) {
+ self.training = training
+ self.storage = storage
+ }
+}
+
+public struct MistralModelUsageData: Codable, Sendable, Equatable {
+ public let input: [MistralUsageEntry]?
+ public let output: [MistralUsageEntry]?
+ public let cached: [MistralUsageEntry]?
+
+ public init(input: [MistralUsageEntry]?, output: [MistralUsageEntry]?, cached: [MistralUsageEntry]?) {
+ self.input = input
+ self.output = output
+ self.cached = cached
+ }
+}
+
+public struct MistralUsageEntry: Codable, Sendable, Equatable {
+ public let usageType: String?
+ public let eventType: String?
+ public let billingMetric: String?
+ public let billingDisplayName: String?
+ public let billingGroup: String?
+ public let timestamp: String?
+ public let value: Double?
+ public let valuePaid: Double?
+
+ enum CodingKeys: String, CodingKey {
+ case usageType = "usage_type"
+ case eventType = "event_type"
+ case billingMetric = "billing_metric"
+ case billingDisplayName = "billing_display_name"
+ case billingGroup = "billing_group"
+ case timestamp
+ case value
+ case valuePaid = "value_paid"
+ }
+}
+
+public struct MistralPrice: Codable, Sendable, Equatable {
+ public let eventType: String?
+ public let billingMetric: String?
+ public let billingGroup: String?
+ public let price: String?
+
+ enum CodingKeys: String, CodingKey {
+ case eventType = "event_type"
+ case billingMetric = "billing_metric"
+ case billingGroup = "billing_group"
+ case price
+ }
+}
+
+public struct MistralWorkspaceUsageSnapshot: Codable, Sendable, Equatable {
+ public let name: String
+ public let totalCost: Double
+ public let totalInputTokens: Int
+ public let totalOutputTokens: Int
+ public let totalCachedTokens: Int
+ public let modelCount: Int
+
+ public init(
+ name: String,
+ totalCost: Double,
+ totalInputTokens: Int,
+ totalOutputTokens: Int,
+ totalCachedTokens: Int,
+ modelCount: Int)
+ {
+ self.name = name
+ self.totalCost = totalCost
+ self.totalInputTokens = totalInputTokens
+ self.totalOutputTokens = totalOutputTokens
+ self.totalCachedTokens = totalCachedTokens
+ self.modelCount = modelCount
+ }
+}
+
+public struct MistralUsageSummarySnapshot: Codable, Sendable, Equatable {
+ public enum SourceKind: String, Codable, Sendable {
+ case web
+ case api
+ }
+
+ public let sourceKind: SourceKind
+ public let modelCount: Int
+ public let previewModelNames: String?
+ public let workspaceSummary: String?
+ public let totalCost: Double?
+ public let currencyCode: String?
+ public let currencySymbol: String?
+ public let totalInputTokens: Int?
+ public let totalOutputTokens: Int?
+ public let totalCachedTokens: Int?
+ public let periodStart: Date?
+ public let periodEnd: Date?
+ public let workspaces: [MistralWorkspaceUsageSnapshot]
+ public let updatedAt: Date
+
+ public init(
+ sourceKind: SourceKind,
+ modelCount: Int,
+ previewModelNames: String?,
+ workspaceSummary: String?,
+ totalCost: Double?,
+ currencyCode: String?,
+ currencySymbol: String?,
+ totalInputTokens: Int?,
+ totalOutputTokens: Int?,
+ totalCachedTokens: Int?,
+ periodStart: Date?,
+ periodEnd: Date?,
+ workspaces: [MistralWorkspaceUsageSnapshot] = [],
+ updatedAt: Date)
+ {
+ self.sourceKind = sourceKind
+ self.modelCount = modelCount
+ self.previewModelNames = previewModelNames
+ self.workspaceSummary = workspaceSummary
+ self.totalCost = totalCost
+ self.currencyCode = currencyCode
+ self.currencySymbol = currencySymbol
+ self.totalInputTokens = totalInputTokens
+ self.totalOutputTokens = totalOutputTokens
+ self.totalCachedTokens = totalCachedTokens
+ self.periodStart = periodStart
+ self.periodEnd = periodEnd
+ self.workspaces = workspaces
+ self.updatedAt = updatedAt
+ }
+
+ public var totalTokens: Int? {
+ guard let totalInputTokens, let totalOutputTokens else { return nil }
+ return totalInputTokens + totalOutputTokens + (totalCachedTokens ?? 0)
+ }
+
+ public var billingPeriodLabel: String? {
+ let formatter = DateFormatter()
+ formatter.locale = Locale.autoupdatingCurrent
+ formatter.dateFormat = "LLLL yyyy"
+ if let periodStart {
+ return formatter.string(from: periodStart)
+ }
+ if let periodEnd {
+ return formatter.string(from: periodEnd)
+ }
+ return nil
+ }
+
+ public var resetsAt: Date? {
+ guard let periodEnd else { return nil }
+ return Calendar.autoupdatingCurrent.date(byAdding: .second, value: 1, to: periodEnd) ?? periodEnd
+ }
+
+ public var tokenSummaryLine: String? {
+ guard let totalInputTokens, let totalOutputTokens else { return nil }
+ var parts = [
+ "In: \(UsageFormatter.tokenCountString(totalInputTokens))",
+ "Out: \(UsageFormatter.tokenCountString(totalOutputTokens))",
+ ]
+ if let totalCachedTokens, totalCachedTokens > 0 {
+ parts.append("Cached: \(UsageFormatter.tokenCountString(totalCachedTokens))")
+ }
+ return parts.joined(separator: " · ")
+ }
+
+ public var workspaceLine: String? {
+ if !self.workspaces.isEmpty {
+ let names = self.workspaces.map(\.name)
+ let preview = names.prefix(2).joined(separator: ", ")
+ if names.count > 2 {
+ return "\(preview) + \(names.count - 2) more workspaces"
+ }
+ return preview
+ }
+ return self.workspaceSummary
+ }
+
+ public var modelsLine: String? {
+ switch self.sourceKind {
+ case .web:
+ guard self.modelCount > 0 else { return nil }
+ return "\(self.modelCount) billed models"
+ case .api:
+ guard self.modelCount > 0 else { return nil }
+ return "\(self.modelCount) models available"
+ }
+ }
+}
+
+public struct MistralAPIUsageSnapshot: Sendable, Equatable {
+ public let models: [MistralModelCard]
+ public let rateLimits: MistralRateLimitSnapshot?
+ public let updatedAt: Date
+
+ public init(models: [MistralModelCard], rateLimits: MistralRateLimitSnapshot?, updatedAt: Date) {
+ self.models = models
+ self.rateLimits = rateLimits
+ self.updatedAt = updatedAt
+ }
+
+ public var modelCount: Int {
+ self.models.count
+ }
+
+ public var accessibleModelNames: [String] {
+ var seen: Set = []
+ return self.models
+ .map(\.displayName)
+ .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+ .filter { seen.insert($0).inserted }
+ }
+
+ public var previewModelNames: String? {
+ let names = Array(self.accessibleModelNames.prefix(3))
+ guard !names.isEmpty else { return nil }
+ if self.accessibleModelNames.count > names.count {
+ return names.joined(separator: ", ") + " + \(self.accessibleModelNames.count - names.count) more"
+ }
+ return names.joined(separator: ", ")
+ }
+
+ public var workspaceNames: [String] {
+ var seen: Set = []
+ return self.models
+ .compactMap(\.workspaceOwner)
+ .filter { seen.insert($0).inserted }
+ }
+
+ public var workspaceSummary: String? {
+ switch self.workspaceNames.count {
+ case 0:
+ return nil
+ case 1:
+ return self.workspaceNames.first
+ default:
+ return "\(self.workspaceNames.count) workspaces"
+ }
+ }
+
+ public var loginSummary: String? {
+ if self.modelCount > 0 {
+ return "\(self.modelCount) models"
+ }
+ return "Connected"
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let windows = self.rateLimits?.orderedWindows ?? []
+ let primary = windows.first?.asRateWindow()
+ let secondary = windows.dropFirst().first?.asRateWindow()
+ let summary = MistralUsageSummarySnapshot(
+ sourceKind: .api,
+ modelCount: self.modelCount,
+ previewModelNames: self.previewModelNames,
+ workspaceSummary: self.workspaceSummary,
+ totalCost: nil,
+ currencyCode: nil,
+ currencySymbol: nil,
+ totalInputTokens: nil,
+ totalOutputTokens: nil,
+ totalCachedTokens: nil,
+ periodStart: nil,
+ periodEnd: nil,
+ workspaces: [],
+ updatedAt: self.updatedAt)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ providerCost: nil,
+ mistralUsage: summary,
+ updatedAt: self.updatedAt,
+ identity: ProviderIdentitySnapshot(
+ providerID: .mistral,
+ accountEmail: nil,
+ accountOrganization: self.workspaceSummary,
+ loginMethod: self.loginSummary))
+ }
+}
+
+public struct MistralBillingUsageSnapshot: Sendable, Equatable {
+ 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 workspaces: [MistralWorkspaceUsageSnapshot]
+ public let updatedAt: Date
+
+ public init(
+ totalCost: Double,
+ currency: String,
+ currencySymbol: String,
+ totalInputTokens: Int,
+ totalOutputTokens: Int,
+ totalCachedTokens: Int,
+ modelCount: Int,
+ startDate: Date?,
+ endDate: Date?,
+ workspaces: [MistralWorkspaceUsageSnapshot],
+ updatedAt: Date)
+ {
+ self.totalCost = totalCost
+ self.currency = currency
+ self.currencySymbol = currencySymbol
+ self.totalInputTokens = totalInputTokens
+ self.totalOutputTokens = totalOutputTokens
+ self.totalCachedTokens = totalCachedTokens
+ self.modelCount = modelCount
+ self.startDate = startDate
+ self.endDate = endDate
+ self.workspaces = workspaces
+ self.updatedAt = updatedAt
+ }
+
+ public var workspaceSummary: String? {
+ switch self.workspaces.count {
+ case 0:
+ return nil
+ case 1:
+ return self.workspaces.first?.name
+ default:
+ return "\(self.workspaces.count) workspaces"
+ }
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let resetDate = self.endDate.map { Calendar.autoupdatingCurrent.date(byAdding: .second, value: 1, to: $0) ?? $0 }
+ let summary = MistralUsageSummarySnapshot(
+ sourceKind: .web,
+ modelCount: self.modelCount,
+ previewModelNames: nil,
+ workspaceSummary: self.workspaceSummary,
+ totalCost: self.totalCost,
+ currencyCode: self.currency,
+ currencySymbol: self.currencySymbol,
+ totalInputTokens: self.totalInputTokens,
+ totalOutputTokens: self.totalOutputTokens,
+ totalCachedTokens: self.totalCachedTokens,
+ periodStart: self.startDate,
+ periodEnd: self.endDate,
+ workspaces: self.workspaces,
+ updatedAt: self.updatedAt)
+
+ return UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: ProviderCostSnapshot(
+ used: self.totalCost,
+ limit: 0,
+ currencyCode: self.currency,
+ period: summary.billingPeriodLabel ?? "Current month",
+ resetsAt: resetDate,
+ updatedAt: self.updatedAt),
+ mistralUsage: summary,
+ updatedAt: self.updatedAt,
+ identity: ProviderIdentitySnapshot(
+ providerID: .mistral,
+ accountEmail: nil,
+ accountOrganization: self.workspaceSummary,
+ loginMethod: summary.billingPeriodLabel ?? "Current month"))
+ }
+}
+
+public enum MistralUsageError: LocalizedError, Sendable, Equatable {
+ case missingToken
+ case missingCookie
+ case invalidCookie
+ case unauthorized
+ case invalidCredentials
+ case rateLimited(retryAfter: Date?)
+ case unexpectedStatus(code: Int, body: String?)
+ case invalidResponse
+ case networkError(String)
+ case decodeFailed(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingToken:
+ return "Mistral API key missing. Set MISTRAL_API_KEY or configure the key in CodexBar settings."
+ case .missingCookie:
+ return "No Mistral AI Studio session cookies found. Sign into console.mistral.ai or paste a Cookie header."
+ case .invalidCookie:
+ return "Mistral cookie header is invalid. It must include an ory_session_* cookie."
+ case .unauthorized:
+ return "Mistral authentication failed (401/403). Check the API key in console.mistral.ai."
+ case .invalidCredentials:
+ return "Mistral AI Studio session expired or is no longer valid. Refresh your cookies and try again."
+ case let .rateLimited(retryAfter):
+ if let retryAfter {
+ return "Mistral API rate limited. Try again after \(Self.formatDate(retryAfter))."
+ }
+ return "Mistral API rate limited. Try again later."
+ case let .unexpectedStatus(code, body):
+ if let body, !body.isEmpty {
+ return "Mistral request failed (HTTP \(code)): \(body)"
+ }
+ return "Mistral request failed (HTTP \(code))."
+ case .invalidResponse:
+ return "Mistral returned an invalid response."
+ case let .networkError(message):
+ return "Mistral network error: \(message)"
+ case let .decodeFailed(message):
+ return "Mistral response could not be decoded: \(message)"
+ case let .parseFailed(message):
+ return "Mistral billing response could not be parsed: \(message)"
+ }
+ }
+
+ private static func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.locale = Locale.autoupdatingCurrent
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .short
+ return formatter.string(from: date)
+ }
+}
+
+public enum MistralSettingsError: LocalizedError, Sendable, Equatable {
+ case missingCookie
+ case invalidCookie
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCookie:
+ return "No Mistral session cookies found in browsers."
+ case .invalidCookie:
+ return "Mistral cookie header is invalid or missing an ory_session_* cookie."
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralSettingsReader.swift b/Sources/CodexBarCore/Providers/Mistral/MistralSettingsReader.swift
new file mode 100644
index 000000000..865018ca9
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralSettingsReader.swift
@@ -0,0 +1,91 @@
+import Foundation
+
+public enum MistralSettingsReader {
+ public static let apiKeyEnvironmentKey = "MISTRAL_API_KEY"
+ public static let apiTokenKey = apiKeyEnvironmentKey
+ public static let manualCookieEnvironmentKeys = [
+ "MISTRAL_COOKIE_HEADER",
+ "MISTRAL_COOKIE",
+ "MISTRAL_MANUAL_COOKIE",
+ ]
+ public static let csrfTokenEnvironmentKeys = [
+ "MISTRAL_CSRF_TOKEN",
+ ]
+
+ public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.cleaned(environment[self.apiKeyEnvironmentKey])
+ }
+
+ public static func apiToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.apiKey(environment: environment)
+ }
+
+ public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
+ if let override = self.cleaned(environment["MISTRAL_API_URL"]),
+ let url = URL(string: override)
+ {
+ return url
+ }
+ return URL(string: "https://api.mistral.ai/v1")!
+ }
+
+ public static func adminURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
+ if let override = self.cleaned(environment["MISTRAL_ADMIN_URL"]),
+ let url = URL(string: override)
+ {
+ return url
+ }
+ return URL(string: "https://admin.mistral.ai")!
+ }
+
+ public static func consoleURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
+ if let override = self.cleaned(environment["MISTRAL_CONSOLE_URL"]),
+ let url = URL(string: override)
+ {
+ return url
+ }
+ return URL(string: "https://console.mistral.ai")!
+ }
+
+ public static func csrfToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ for key in self.csrfTokenEnvironmentKeys {
+ if let value = self.cleaned(environment[key]) {
+ return value
+ }
+ }
+ return nil
+ }
+
+ public static func cookieHeader(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ for key in self.manualCookieEnvironmentKeys {
+ if let value = CookieHeaderNormalizer.normalize(environment[key]) {
+ return value
+ }
+ }
+ return nil
+ }
+
+ public static func cookieOverride(environment: [String: String] = ProcessInfo.processInfo.environment)
+ -> MistralCookieOverride?
+ {
+ MistralCookieHeader.override(
+ from: self.cookieHeader(environment: environment),
+ explicitCSRFToken: self.csrfToken(environment: environment))
+ }
+
+ static func cleaned(_ raw: String?) -> String? {
+ guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+
+ value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralStrategies.swift b/Sources/CodexBarCore/Providers/Mistral/MistralStrategies.swift
new file mode 100644
index 000000000..98f3aa7eb
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralStrategies.swift
@@ -0,0 +1,174 @@
+import Foundation
+
+struct MistralWebFetchStrategy: ProviderFetchStrategy {
+ private enum CookieSourceKind {
+ case manual
+ case environment
+ case cache
+ case browser
+
+ var shouldCacheAfterFetch: Bool {
+ self == .browser
+ }
+ }
+
+ private struct ResolvedCookie {
+ let override: MistralCookieOverride
+ let source: CookieSourceKind
+ let sourceLabel: String
+ }
+
+ let id: String = "mistral.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ guard context.settings?.mistral?.cookieSource != .off else { return false }
+
+ if context.settings?.mistral?.cookieSource == .manual {
+ return MistralCookieHeader.resolveCookieOverride(context: context) != nil
+ }
+
+ if MistralSettingsReader.cookieOverride(environment: context.env) != nil {
+ return true
+ }
+
+ if let cached = CookieHeaderCache.load(provider: .mistral),
+ MistralCookieHeader.override(from: cached.cookieHeader) != nil
+ {
+ return true
+ }
+
+ #if os(macOS)
+ return MistralCookieImporter.hasSession(browserDetection: context.browserDetection)
+ #else
+ return false
+ #endif
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let cookieSource = context.settings?.mistral?.cookieSource ?? .auto
+ let resolvedCookie = try Self.resolveCookie(context: context, allowCached: true)
+
+ do {
+ let usage = try await MistralFetcher.fetchBillingUsage(
+ cookieHeader: resolvedCookie.override.cookieHeader,
+ csrfToken: resolvedCookie.override.csrfToken,
+ environment: context.env,
+ timeout: context.webTimeout)
+ Self.cacheCookieIfNeeded(resolvedCookie)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "web")
+ } catch MistralUsageError.invalidCredentials where cookieSource != .manual {
+ #if os(macOS)
+ if resolvedCookie.source == .cache {
+ CookieHeaderCache.clear(provider: .mistral)
+ }
+ let refreshedCookie = try Self.resolveCookie(context: context, allowCached: false)
+ let usage = try await MistralFetcher.fetchBillingUsage(
+ cookieHeader: refreshedCookie.override.cookieHeader,
+ csrfToken: refreshedCookie.override.csrfToken,
+ environment: context.env,
+ timeout: context.webTimeout)
+ Self.cacheCookieIfNeeded(refreshedCookie)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "web")
+ #else
+ throw MistralUsageError.invalidCredentials
+ #endif
+ }
+ }
+
+ func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool {
+ guard context.sourceMode == .auto else { return false }
+
+ if error is URLError {
+ return true
+ }
+
+ guard let mistralError = error as? MistralUsageError else { return false }
+ switch mistralError {
+ case .missingCookie,
+ .invalidCookie,
+ .invalidCredentials,
+ .invalidResponse,
+ .networkError,
+ .decodeFailed,
+ .parseFailed,
+ .unexpectedStatus:
+ return true
+ case .missingToken,
+ .unauthorized,
+ .rateLimited:
+ return false
+ }
+ }
+
+ private static func resolveCookie(context: ProviderFetchContext, allowCached: Bool) throws -> ResolvedCookie {
+ if context.settings?.mistral?.cookieSource == .manual {
+ guard let override = MistralCookieHeader.resolveCookieOverride(context: context) else {
+ throw MistralUsageError.invalidCookie
+ }
+ return ResolvedCookie(override: override, source: .manual, sourceLabel: "manual")
+ }
+
+ if let envOverride = MistralSettingsReader.cookieOverride(environment: context.env) {
+ return ResolvedCookie(override: envOverride, source: .environment, sourceLabel: "environment")
+ }
+
+ if allowCached,
+ let cached = CookieHeaderCache.load(provider: .mistral),
+ let cachedOverride = MistralCookieHeader.override(from: cached.cookieHeader)
+ {
+ return ResolvedCookie(override: cachedOverride, source: .cache, sourceLabel: cached.sourceLabel)
+ }
+
+ #if os(macOS)
+ let session = try MistralCookieImporter.importSession(browserDetection: context.browserDetection)
+ guard let browserOverride = session.cookieOverride else {
+ throw MistralUsageError.missingCookie
+ }
+ return ResolvedCookie(override: browserOverride, source: .browser, sourceLabel: session.sourceLabel)
+ #else
+ throw MistralUsageError.missingCookie
+ #endif
+ }
+
+ private static func cacheCookieIfNeeded(_ cookie: ResolvedCookie) {
+ guard cookie.source.shouldCacheAfterFetch else { return }
+ CookieHeaderCache.store(
+ provider: .mistral,
+ cookieHeader: cookie.override.cookieHeader,
+ sourceLabel: cookie.sourceLabel)
+ }
+}
+
+struct MistralAPIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "mistral.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ Self.resolveToken(environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = Self.resolveToken(environment: context.env) else {
+ throw MistralUsageError.missingToken
+ }
+ let usage = try await MistralFetcher.fetchUsage(
+ apiKey: apiKey,
+ environment: context.env)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "api")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveToken(environment: [String: String]) -> String? {
+ ProviderTokenResolver.mistralToken(environment: environment)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 236af4bd3..69ab9d8ba 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -75,6 +75,7 @@ public enum ProviderDescriptorRegistry {
.ollama: OllamaProviderDescriptor.descriptor,
.synthetic: SyntheticProviderDescriptor.descriptor,
.openrouter: OpenRouterProviderDescriptor.descriptor,
+ .mistral: MistralProviderDescriptor.descriptor,
.warp: WarpProviderDescriptor.descriptor,
.perplexity: PerplexityProviderDescriptor.descriptor,
]
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index 7050e2b23..5288f0874 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -19,7 +19,8 @@ public struct ProviderSettingsSnapshot: Sendable {
amp: AmpProviderSettings? = nil,
ollama: OllamaProviderSettings? = nil,
jetbrains: JetBrainsProviderSettings? = nil,
- perplexity: PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot
+ perplexity: PerplexityProviderSettings? = nil,
+ mistral: MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
debugMenuEnabled: debugMenuEnabled,
@@ -39,7 +40,8 @@ public struct ProviderSettingsSnapshot: Sendable {
amp: amp,
ollama: ollama,
jetbrains: jetbrains,
- perplexity: perplexity)
+ perplexity: perplexity,
+ mistral: mistral)
}
public struct CodexProviderSettings: Sendable {
@@ -227,6 +229,22 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct MistralProviderSettings: Sendable {
+ public let cookieSource: ProviderCookieSource
+ public let manualCookieHeader: String?
+ public let prefersAPIInAuto: Bool
+
+ public init(
+ cookieSource: ProviderCookieSource,
+ manualCookieHeader: String?,
+ prefersAPIInAuto: Bool = false)
+ {
+ self.cookieSource = cookieSource
+ self.manualCookieHeader = manualCookieHeader
+ self.prefersAPIInAuto = prefersAPIInAuto
+ }
+ }
+
public let debugMenuEnabled: Bool
public let debugKeepCLISessionsAlive: Bool
public let codex: CodexProviderSettings?
@@ -245,6 +263,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let ollama: OllamaProviderSettings?
public let jetbrains: JetBrainsProviderSettings?
public let perplexity: PerplexityProviderSettings?
+ public let mistral: MistralProviderSettings?
public var jetbrainsIDEBasePath: String? {
self.jetbrains?.ideBasePath
@@ -268,7 +287,8 @@ public struct ProviderSettingsSnapshot: Sendable {
amp: AmpProviderSettings?,
ollama: OllamaProviderSettings?,
jetbrains: JetBrainsProviderSettings? = nil,
- perplexity: PerplexityProviderSettings? = nil)
+ perplexity: PerplexityProviderSettings? = nil,
+ mistral: MistralProviderSettings? = nil)
{
self.debugMenuEnabled = debugMenuEnabled
self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive
@@ -288,6 +308,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.ollama = ollama
self.jetbrains = jetbrains
self.perplexity = perplexity
+ self.mistral = mistral
}
}
@@ -308,6 +329,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case ollama(ProviderSettingsSnapshot.OllamaProviderSettings)
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings)
+ case mistral(ProviderSettingsSnapshot.MistralProviderSettings)
}
public struct ProviderSettingsSnapshotBuilder: Sendable {
@@ -329,6 +351,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings?
public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings?
public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings?
+ public var mistral: ProviderSettingsSnapshot.MistralProviderSettings?
public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) {
self.debugMenuEnabled = debugMenuEnabled
@@ -353,6 +376,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .ollama(value): self.ollama = value
case let .jetbrains(value): self.jetbrains = value
case let .perplexity(value): self.perplexity = value
+ case let .mistral(value): self.mistral = value
}
}
@@ -375,6 +399,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
amp: self.amp,
ollama: self.ollama,
jetbrains: self.jetbrains,
- perplexity: self.perplexity)
+ perplexity: self.perplexity,
+ mistral: self.mistral)
}
}
diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
index 85113cc26..f100f83bd 100644
--- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
+++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
@@ -65,6 +65,10 @@ public enum ProviderTokenResolver {
self.openRouterResolution(environment: environment)?.token
}
+ public static func mistralToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.mistralResolution(environment: environment)?.token
+ }
+
public static func perplexitySessionToken(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
@@ -157,6 +161,12 @@ public enum ProviderTokenResolver {
self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment))
}
+ public static func mistralResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(MistralSettingsReader.apiKey(environment: environment))
+ }
+
public static func perplexityResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index ff0f8eeb4..cdeed263d 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
case perplexity
}
@@ -55,6 +56,7 @@ public enum IconStyle: Sendable, CaseIterable {
case synthetic
case warp
case openrouter
+ case mistral
case perplexity
case combined
}
diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
index 2a1d0f1d4..ecbf8437c 100644
--- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
+++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
@@ -16,6 +16,13 @@ extension TokenAccountSupportCatalog {
injection: .environment(key: ZaiSettingsReader.apiTokenKey),
requiresManualCookieSource: false,
cookieName: nil),
+ .mistral: TokenAccountSupport(
+ title: "API keys (Optional)",
+ subtitle: "Optional. Store multiple Mistral API keys for API fallback and CLI use.",
+ placeholder: "Paste API key…",
+ injection: .environment(key: MistralSettingsReader.apiKeyEnvironmentKey),
+ requiresManualCookieSource: false,
+ cookieName: nil),
.cursor: TokenAccountSupport(
title: "Session tokens",
subtitle: "Store multiple Cursor Cookie headers.",
diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift
index 9731c0891..03e6cb489 100644
--- a/Sources/CodexBarCore/UsageFetcher.swift
+++ b/Sources/CodexBarCore/UsageFetcher.swift
@@ -55,6 +55,7 @@ public struct UsageSnapshot: Codable, Sendable {
public let zaiUsage: ZaiUsageSnapshot?
public let minimaxUsage: MiniMaxUsageSnapshot?
public let openRouterUsage: OpenRouterUsageSnapshot?
+ public let mistralUsage: MistralUsageSummarySnapshot?
public let cursorRequests: CursorRequestUsage?
public let updatedAt: Date
public let identity: ProviderIdentitySnapshot?
@@ -65,6 +66,7 @@ public struct UsageSnapshot: Codable, Sendable {
case tertiary
case providerCost
case openRouterUsage
+ case mistralUsage
case updatedAt
case identity
case accountEmail
@@ -80,6 +82,7 @@ public struct UsageSnapshot: Codable, Sendable {
zaiUsage: ZaiUsageSnapshot? = nil,
minimaxUsage: MiniMaxUsageSnapshot? = nil,
openRouterUsage: OpenRouterUsageSnapshot? = nil,
+ mistralUsage: MistralUsageSummarySnapshot? = nil,
cursorRequests: CursorRequestUsage? = nil,
updatedAt: Date,
identity: ProviderIdentitySnapshot? = nil)
@@ -91,6 +94,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.zaiUsage = zaiUsage
self.minimaxUsage = minimaxUsage
self.openRouterUsage = openRouterUsage
+ self.mistralUsage = mistralUsage
self.cursorRequests = cursorRequests
self.updatedAt = updatedAt
self.identity = identity
@@ -105,6 +109,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.zaiUsage = nil // Not persisted, fetched fresh each time
self.minimaxUsage = nil // Not persisted, fetched fresh each time
self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage)
+ self.mistralUsage = try container.decodeIfPresent(MistralUsageSummarySnapshot.self, forKey: .mistralUsage)
self.cursorRequests = nil // Not persisted, fetched fresh each time
self.updatedAt = try container.decode(Date.self, forKey: .updatedAt)
if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) {
@@ -133,6 +138,7 @@ public struct UsageSnapshot: Codable, Sendable {
try container.encode(self.tertiary, forKey: .tertiary)
try container.encodeIfPresent(self.providerCost, forKey: .providerCost)
try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage)
+ try container.encodeIfPresent(self.mistralUsage, forKey: .mistralUsage)
try container.encode(self.updatedAt, forKey: .updatedAt)
try container.encodeIfPresent(self.identity, forKey: .identity)
try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail)
@@ -219,6 +225,7 @@ public struct UsageSnapshot: Codable, Sendable {
zaiUsage: self.zaiUsage,
minimaxUsage: self.minimaxUsage,
openRouterUsage: self.openRouterUsage,
+ mistralUsage: self.mistralUsage,
cursorRequests: self.cursorRequests,
updatedAt: self.updatedAt,
identity: identity)
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index f4a3ba8bb..dae9b44d4 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -72,7 +72,8 @@ 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, .perplexity:
+ .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .mistral, .warp,
+ .perplexity:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index c0e600fa9..fd3875117 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -70,6 +70,7 @@ enum ProviderChoice: String, AppEnum {
case .ollama: return nil // Ollama not yet supported in widgets
case .synthetic: return nil // Synthetic not yet supported in widgets
case .openrouter: return nil // OpenRouter not yet supported in widgets
+ case .mistral: return nil // Mistral not yet supported in widgets
case .warp: return nil // Warp not yet supported in widgets
case .perplexity: return nil // Perplexity not yet supported in widgets
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 679354311..c473240c5 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -279,6 +279,7 @@ private struct ProviderSwitchChip: View {
case .ollama: "Ollama"
case .synthetic: "Synthetic"
case .openrouter: "OpenRouter"
+ case .mistral: "Mistral"
case .warp: "Warp"
case .perplexity: "Pplx"
}
@@ -641,6 +642,8 @@ enum WidgetColors {
Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal
case .openrouter:
Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple
+ case .mistral:
+ Color(red: 112 / 255, green: 86 / 255, blue: 255 / 255)
case .warp:
Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255)
case .perplexity:
diff --git a/Tests/CodexBarTests/MistralProviderTests.swift b/Tests/CodexBarTests/MistralProviderTests.swift
new file mode 100644
index 000000000..e25177768
--- /dev/null
+++ b/Tests/CodexBarTests/MistralProviderTests.swift
@@ -0,0 +1,289 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct MistralSettingsReaderTests {
+ @Test
+ func `api key reads from environment`() {
+ let token = MistralSettingsReader.apiKey(environment: ["MISTRAL_API_KEY": "mistral-test-key"])
+ #expect(token == "mistral-test-key")
+ }
+
+ @Test
+ func `api key strips surrounding quotes`() {
+ let token = MistralSettingsReader.apiKey(environment: ["MISTRAL_API_KEY": "\"mistral-test-key\""])
+ #expect(token == "mistral-test-key")
+ }
+
+ @Test
+ func `api URL supports overrides`() {
+ let url = MistralSettingsReader.apiURL(environment: ["MISTRAL_API_URL": "https://proxy.example/v1"])
+ #expect(url.absoluteString == "https://proxy.example/v1")
+ }
+}
+
+struct MistralUsageSnapshotTests {
+ @Test
+ func `parses documented model list response shape`() throws {
+ let data = Data(
+ """
+ {
+ "object": "list",
+ "data": [
+ {
+ "id": "mistral-medium-2508",
+ "object": "model",
+ "created": 1775089283,
+ "owned_by": "mistralai",
+ "capabilities": {
+ "completion_chat": true,
+ "function_calling": true,
+ "reasoning": false,
+ "completion_fim": false,
+ "fine_tuning": true,
+ "vision": true,
+ "ocr": false,
+ "classification": false,
+ "moderation": false,
+ "audio": false,
+ "audio_transcription": false,
+ "audio_transcription_realtime": false,
+ "audio_speech": false
+ },
+ "name": "mistral-medium-2508",
+ "description": "Update on Mistral Medium 3 with improved capabilities.",
+ "max_context_length": 131072,
+ "aliases": ["mistral-medium-latest", "mistral-medium"],
+ "deprecation": null,
+ "deprecation_replacement_model": null,
+ "default_model_temperature": 0.3,
+ "type": "base"
+ }
+ ]
+ }
+ """.utf8)
+
+ let response = try MistralFetcher.parseModelListResponse(data: data)
+
+ #expect(response.object == "list")
+ #expect(response.data.count == 1)
+ #expect(response.data.first?.id == "mistral-medium-2508")
+ #expect(response.data.first?.capabilities.functionCalling == true)
+ #expect(response.data.first?.aliases == ["mistral-medium-latest", "mistral-medium"])
+ }
+
+ @Test
+ func `maps rate limit windows into usage snapshot`() {
+ let requests = MistralRateLimitWindow(
+ kind: "requests",
+ limit: 120,
+ remaining: 30,
+ resetsAt: Date(timeIntervalSince1970: 1_700_000_000),
+ resetDescription: "Requests: 30/120")
+ let tokens = MistralRateLimitWindow(
+ kind: "tokens",
+ limit: 1000,
+ remaining: 400,
+ resetsAt: Date(timeIntervalSince1970: 1_700_000_600),
+ resetDescription: "Tokens: 400/1000")
+ let model = MistralModelCard(
+ id: "codestral-latest",
+ object: "model",
+ created: nil,
+ ownedBy: "workspace-123",
+ capabilities: MistralModelCapabilities(
+ completionChat: true,
+ completionFim: true,
+ functionCalling: false,
+ fineTuning: false,
+ vision: false,
+ ocr: false,
+ classification: false,
+ moderation: false,
+ audio: false,
+ audioTranscription: false),
+ name: "Codestral",
+ description: nil,
+ maxContextLength: 262_144,
+ aliases: [],
+ deprecation: nil,
+ deprecationReplacementModel: nil,
+ defaultModelTemperature: nil,
+ type: "base",
+ job: nil,
+ root: nil,
+ archived: false)
+ let snapshot = MistralUsageSnapshot(
+ models: [model],
+ rateLimits: MistralRateLimitSnapshot(
+ requests: requests,
+ tokens: tokens,
+ retryAfter: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_100))
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 75)
+ #expect(usage.secondary?.usedPercent == 60)
+ #expect(usage.accountOrganization(for: .mistral) == "workspace-123")
+ #expect(usage.loginMethod(for: .mistral)?.contains("Codestral") == true)
+ }
+
+ @Test
+ func `deduplicates preview model names while preserving total model count`() {
+ let models = [
+ MistralModelCard(
+ id: "mistral-medium-2508",
+ object: "model",
+ created: nil,
+ ownedBy: "mistralai",
+ capabilities: MistralModelCapabilities(completionChat: true),
+ name: "mistral-medium-2508",
+ description: nil,
+ maxContextLength: nil,
+ aliases: [],
+ deprecation: nil,
+ deprecationReplacementModel: nil,
+ defaultModelTemperature: nil,
+ type: "base",
+ job: nil,
+ root: nil,
+ archived: false),
+ MistralModelCard(
+ id: "mistral-medium-latest",
+ object: "model",
+ created: nil,
+ ownedBy: "mistralai",
+ capabilities: MistralModelCapabilities(completionChat: true),
+ name: "mistral-medium-2508",
+ description: nil,
+ maxContextLength: nil,
+ aliases: [],
+ deprecation: nil,
+ deprecationReplacementModel: nil,
+ defaultModelTemperature: nil,
+ type: "base",
+ job: nil,
+ root: nil,
+ archived: false),
+ MistralModelCard(
+ id: "codestral-latest",
+ object: "model",
+ created: nil,
+ ownedBy: "workspace-123",
+ capabilities: MistralModelCapabilities(completionChat: true, completionFim: true),
+ name: "Codestral",
+ description: nil,
+ maxContextLength: nil,
+ aliases: [],
+ deprecation: nil,
+ deprecationReplacementModel: nil,
+ defaultModelTemperature: nil,
+ type: "base",
+ job: nil,
+ root: nil,
+ archived: false,
+ )
+ ]
+
+ let snapshot = MistralUsageSnapshot(models: models, rateLimits: nil, updatedAt: .distantPast)
+
+ #expect(snapshot.modelCount == 3)
+ #expect(snapshot.accessibleModelNames == ["mistral-medium-2508", "Codestral"])
+ #expect(snapshot.loginSummary == "3 models")
+ }
+}
+
+struct MistralSharedIntegrationTests {
+ @Test
+ func `provider settings snapshot keeps mistral hybrid cookie settings`() {
+ let snapshot = ProviderSettingsSnapshot.make(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: .manual,
+ manualCookieHeader: "ory_session=abc; csrftoken=def"))
+
+ #expect(snapshot.mistral?.cookieSource == .manual)
+ #expect(snapshot.mistral?.manualCookieHeader == "ory_session=abc; csrftoken=def")
+ #expect(snapshot.mistral?.prefersAPIInAuto == false)
+ }
+
+ @Test
+ func `provider settings builder applies mistral contribution`() {
+ var builder = ProviderSettingsSnapshotBuilder()
+ builder.apply(
+ .mistral(
+ ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil,
+ ))
+ )
+
+ let snapshot = builder.build()
+
+ #expect(snapshot.mistral?.cookieSource == .auto)
+ #expect(snapshot.mistral?.manualCookieHeader == nil)
+ #expect(snapshot.mistral?.prefersAPIInAuto == false)
+ }
+
+ @Test
+ func `mistral token account snapshots prefer api in auto mode`() {
+ let snapshot = ProviderSettingsSnapshot.make(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil,
+ prefersAPIInAuto: true))
+
+ #expect(snapshot.mistral?.prefersAPIInAuto == true)
+ }
+
+ @Test
+ func `usage snapshot round trips persisted mistral monthly summary`() throws {
+ let summary = MistralUsageSummarySnapshot(
+ sourceKind: .web,
+ modelCount: 3,
+ previewModelNames: "codestral-latest, mistral-medium-latest",
+ workspaceSummary: "workspace-123",
+ totalCost: 12.34,
+ currencyCode: "USD",
+ currencySymbol: "$",
+ totalInputTokens: 1000,
+ totalOutputTokens: 500,
+ totalCachedTokens: 250,
+ periodStart: Date(timeIntervalSince1970: 1_796_083_200),
+ periodEnd: Date(timeIntervalSince1970: 1_798_761_599),
+ workspaces: [],
+ )
+ let usage = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 25,
+ windowMinutes: nil,
+ resetsAt: Date(timeIntervalSince1970: 1_800_000_000),
+ resetDescription: "Monthly"),
+ secondary: nil,
+ tertiary: nil,
+ providerCost: ProviderCostSnapshot(
+ used: 12.34,
+ limit: 50,
+ currencyCode: "USD",
+ period: "Monthly",
+ resetsAt: Date(timeIntervalSince1970: 1_800_000_000),
+ updatedAt: Date(timeIntervalSince1970: 1_799_000_000)),
+ mistralUsage: summary,
+ updatedAt: Date(timeIntervalSince1970: 1_799_000_100),
+ identity: ProviderIdentitySnapshot(
+ providerID: .mistral,
+ accountEmail: nil,
+ accountOrganization: "workspace-123",
+ loginMethod: "Connected"))
+
+ let encoded = try JSONEncoder().encode(usage)
+ let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded)
+
+ #expect(decoded.mistralUsage == summary)
+ #expect(decoded.mistralUsage?.sourceKind == .web)
+ #expect(decoded.mistralUsage?.totalTokens == 1750)
+ #expect(decoded.mistralUsage?.billingPeriodLabel != nil)
+ #expect(decoded.providerCost?.used == 12.34)
+ #expect(decoded.accountOrganization(for: .mistral) == "workspace-123")
+ }
+}
diff --git a/docs/cli.md b/docs/cli.md
index a13c8f830..c9f915add 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -54,13 +54,14 @@ See `docs/configuration.md` for the schema.
- `web` (macOS only): web-only; no CLI fallback.
- `cli`: CLI-only (Codex RPC → PTY fallback; Claude PTY).
- `oauth`: Claude OAuth only (debug); no fallback. Not supported for Codex.
- - `api`: API key flow when the provider supports it (z.ai, Gemini, Copilot, Kilo, Kimi K2, MiniMax, Warp, OpenRouter, Synthetic).
+ - `api`: API key flow when the provider supports it (z.ai, Gemini, Copilot, Kilo, Kimi K2, MiniMax, Warp, OpenRouter, Mistral, Synthetic).
- Output `source` reflects the strategy actually used (`openai-web`, `web`, `oauth`, `api`, `local`, or provider CLI label).
- Codex web: OpenAI web dashboard (usage limits, credits remaining, code review remaining, usage breakdown).
- `--web-timeout ` (default: 60)
- `--web-debug-dump-html` (writes HTML snapshots to `/tmp` when data is missing)
- Claude web: claude.ai API (session + weekly usage, plus account metadata when available).
- Kilo auto: app.kilo.ai API first, then CLI auth fallback (`~/.local/share/kilo/auth.json`) on missing/unauthorized API credentials.
+ - Mistral auto: admin.mistral.ai billing dashboard via browser/manual cookies first, then public API key fallback for model access.
- Linux: `web/auto` are not supported; CLI prints an error and exits non-zero.
- Global flags: `-h/--help`, `-V/--version`, `-v/--verbose`, `--no-color`, `--log-level `, `--json-output`, `--json-only`.
- `--json-output`: JSONL logs on stderr (machine-readable).
@@ -104,6 +105,8 @@ codexbar --provider claude --all-accounts --format json --pretty
codexbar --json-only --format json --pretty
codexbar --provider gemini --source api --format json --pretty
KILO_API_KEY=... codexbar --provider kilo --source api --format json --pretty
+MISTRAL_API_KEY=... codexbar --provider mistral --source api --format json --pretty
+codexbar --provider mistral --source web --format json --pretty
codexbar config validate --format json --pretty
codexbar config dump --pretty
```
diff --git a/docs/providers.md b/docs/providers.md
index 63f3aeaa0..eb0788c78 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 | Admin dashboard billing via cookies (`web`) → API key model probe (`api`). |
## Codex
- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -182,4 +183,14 @@ 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
+- Auto mode prefers the Mistral admin dashboard (`https://admin.mistral.ai/organization/usage`) via browser cookies, with API fallback when web auth is unavailable.
+- Web auth supports automatic browser-cookie import or a manual `Cookie` header in Settings → Providers → Mistral.
+- Dashboard fetch uses `GET /api/billing/v2/usage?month=&year=` on `admin.mistral.ai` and aggregates the current month's spend/token totals.
+- API auth uses `~/.codexbar/config.json` (`providers[].apiKey`), token-account overrides, or `MISTRAL_API_KEY`.
+- Base URL defaults to `https://api.mistral.ai/v1`; override with `MISTRAL_API_URL`.
+- API fallback uses the documented models endpoint, `GET /models`, to validate the key and list accessible models, plus best-effort request/token rate-limit headers when present.
+- Because Mistral does not document a public billing endpoint, the numeric spend/token dashboard data currently comes from the authenticated AI Studio web session rather than the public API.
+- Status: none yet.
+
See also: `docs/provider.md` for architecture notes.