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. CodexBar menu screenshot @@ -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.