diff --git a/.gitignore b/.gitignore
index 01ae2d5c..b321d151 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ debug_*.swift
# Misc
.DS_Store
+.swiftpm-cache/
# Debug/analysis docs
docs/*-analysis.md
diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift
index 1b808340..c5d4730b 100644
--- a/Sources/CodexBar/PreferencesDebugPane.swift
+++ b/Sources/CodexBar/PreferencesDebugPane.swift
@@ -106,6 +106,7 @@ struct DebugPane: View {
Text("Cursor").tag(UsageProvider.cursor)
Text("Augment").tag(UsageProvider.augment)
Text("Amp").tag(UsageProvider.amp)
+ Text("Ollama").tag(UsageProvider.ollama)
}
.pickerStyle(.segmented)
.frame(width: 460)
@@ -299,6 +300,7 @@ struct DebugPane: View {
Text("Antigravity").tag(UsageProvider.antigravity)
Text("Augment").tag(UsageProvider.augment)
Text("Amp").tag(UsageProvider.amp)
+ Text("Ollama").tag(UsageProvider.ollama)
}
.pickerStyle(.segmented)
.frame(width: 360)
diff --git a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift
new file mode 100644
index 00000000..5d71c5b3
--- /dev/null
+++ b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift
@@ -0,0 +1,81 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct OllamaProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .ollama
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.ollamaCookieSource
+ _ = settings.ollamaCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .ollama(context.settings.ollamaSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.ollamaCookieSource.rawValue },
+ set: { raw in
+ context.settings.ollamaCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let cookieOptions = ProviderCookieSourceUI.options(
+ allowsOff: false,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+
+ let cookieSubtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.ollamaCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Automatic imports browser cookies.",
+ manual: "Paste a Cookie header or cURL capture from Ollama settings.",
+ off: "Ollama cookies are disabled.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "ollama-cookie-source",
+ title: "Cookie source",
+ subtitle: "Automatic imports browser cookies.",
+ dynamicSubtitle: cookieSubtitle,
+ binding: cookieBinding,
+ options: cookieOptions,
+ isVisible: nil,
+ onChange: nil),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "ollama-cookie",
+ title: "",
+ subtitle: "",
+ kind: .secure,
+ placeholder: "Cookie: …",
+ binding: context.stringBinding(\.ollamaCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "ollama-open-settings",
+ title: "Open Ollama Settings",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://ollama.com/settings") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.ollamaCookieSource == .manual },
+ onActivate: { }),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift b/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift
new file mode 100644
index 00000000..a7453457
--- /dev/null
+++ b/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift
@@ -0,0 +1,60 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var ollamaCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .ollama)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .ollama) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .ollama, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var ollamaCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .ollama, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .ollama) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .ollama, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+}
+
+extension SettingsStore {
+ func ollamaSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.OllamaProviderSettings {
+ ProviderSettingsSnapshot.OllamaProviderSettings(
+ cookieSource: self.ollamaSnapshotCookieSource(tokenOverride: tokenOverride),
+ manualCookieHeader: self.ollamaSnapshotCookieHeader(tokenOverride: tokenOverride))
+ }
+
+ private func ollamaSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String {
+ let fallback = self.ollamaCookieHeader
+ guard let support = TokenAccountSupportCatalog.support(for: .ollama),
+ case .cookieHeader = support.injection
+ else {
+ return fallback
+ }
+ guard let account = ProviderTokenAccountSelection.selectedAccount(
+ provider: .ollama,
+ settings: self,
+ override: tokenOverride)
+ else {
+ return fallback
+ }
+ return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support)
+ }
+
+ private func ollamaSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource {
+ let fallback = self.ollamaCookieSource
+ guard let support = TokenAccountSupportCatalog.support(for: .ollama),
+ support.requiresManualCookieSource
+ else {
+ return fallback
+ }
+ if self.tokenAccounts(for: .ollama).isEmpty { return fallback }
+ return .manual
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index f6a9b2a3..a830a875 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -29,6 +29,7 @@ enum ProviderImplementationRegistry {
case .jetbrains: JetBrainsProviderImplementation()
case .kimik2: KimiK2ProviderImplementation()
case .amp: AmpProviderImplementation()
+ case .ollama: OllamaProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-ollama.svg b/Sources/CodexBar/Resources/ProviderIcon-ollama.svg
new file mode 100644
index 00000000..23b80bc5
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-ollama.svg
@@ -0,0 +1,3 @@
+
diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift
index 6c69f6a7..6ff6de19 100644
--- a/Sources/CodexBar/SettingsStore+MenuObservation.swift
+++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift
@@ -36,6 +36,7 @@ extension SettingsStore {
_ = self.kimiCookieSource
_ = self.augmentCookieSource
_ = self.ampCookieSource
+ _ = self.ollamaCookieSource
_ = self.mergeIcons
_ = self.switcherShowsIcons
_ = self.zaiAPIToken
@@ -52,6 +53,7 @@ extension SettingsStore {
_ = self.kimiK2APIToken
_ = self.augmentCookieHeader
_ = self.ampCookieHeader
+ _ = self.ollamaCookieHeader
_ = self.copilotAPIToken
_ = self.tokenAccountsByProvider
_ = self.debugLoadingPattern
diff --git a/Sources/CodexBar/UsageStore+Logging.swift b/Sources/CodexBar/UsageStore+Logging.swift
index 4a72e352..4522a0ff 100644
--- a/Sources/CodexBar/UsageStore+Logging.swift
+++ b/Sources/CodexBar/UsageStore+Logging.swift
@@ -14,6 +14,7 @@ extension UsageStore {
"kimiCookieSource": self.settings.kimiCookieSource.rawValue,
"augmentCookieSource": self.settings.augmentCookieSource.rawValue,
"ampCookieSource": self.settings.ampCookieSource.rawValue,
+ "ollamaCookieSource": self.settings.ollamaCookieSource.rawValue,
"openAIWebAccess": self.settings.openAIWebAccessEnabled ? "1" : "0",
"claudeWebExtras": self.settings.claudeWebExtrasEnabled ? "1" : "0",
]
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 69491056..4f75b087 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1225,6 +1225,12 @@ extension UsageStore {
ampCookieHeader: self.settings.ampCookieHeader)
await MainActor.run { self.probeLogs[.amp] = text }
return text
+ case .ollama:
+ let text = await self.debugOllamaLog(
+ ollamaCookieSource: self.settings.ollamaCookieSource,
+ ollamaCookieHeader: self.settings.ollamaCookieHeader)
+ await MainActor.run { self.probeLogs[.ollama] = text }
+ return text
case .jetbrains:
let text = "JetBrains AI debug log not yet implemented"
await MainActor.run { self.probeLogs[.jetbrains] = text }
@@ -1389,6 +1395,19 @@ extension UsageStore {
}
}
+ private func debugOllamaLog(
+ ollamaCookieSource: ProviderCookieSource,
+ ollamaCookieHeader: String) async -> String
+ {
+ await self.runWithTimeout(seconds: 15) {
+ let fetcher = OllamaUsageFetcher(browserDetection: self.browserDetection)
+ let manualHeader = ollamaCookieSource == .manual
+ ? CookieHeaderNormalizer.normalize(ollamaCookieHeader)
+ : nil
+ return await fetcher.debugRawProbe(cookieHeaderOverride: manualHeader)
+ }
+ }
+
private func runWithTimeout(seconds: Double, operation: @escaping @Sendable () async -> String) async -> String {
await withTaskGroup(of: String?.self) { group -> String in
group.addTask { await operation() }
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index a0a88371..f196cc8f 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -135,6 +135,11 @@ struct TokenAccountCLIContext {
amp: ProviderSettingsSnapshot.AmpProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
+ case .ollama:
+ return self.makeSnapshot(
+ ollama: ProviderSettingsSnapshot.OllamaProviderSettings(
+ cookieSource: cookieSource,
+ manualCookieHeader: cookieHeader))
case .kimi:
return self.makeSnapshot(
kimi: ProviderSettingsSnapshot.KimiProviderSettings(
@@ -163,6 +168,7 @@ struct TokenAccountCLIContext {
kimi: ProviderSettingsSnapshot.KimiProviderSettings? = nil,
augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil,
amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil,
+ ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
@@ -176,6 +182,7 @@ struct TokenAccountCLIContext {
kimi: kimi,
augment: augment,
amp: amp,
+ ollama: ollama,
jetbrains: jetbrains)
}
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index d3d31c8f..300d7cc3 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -40,6 +40,7 @@ public enum LogCategories {
public static let notifications = "notifications"
public static let openAIWeb = "openai-web"
public static let openAIWebview = "openai-webview"
+ public static let ollama = "ollama"
public static let opencodeUsage = "opencode-usage"
public static let providerDetection = "provider-detection"
public static let providers = "providers"
diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift
new file mode 100644
index 00000000..c70b03e8
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift
@@ -0,0 +1,72 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum OllamaProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .ollama,
+ metadata: ProviderMetadata(
+ id: .ollama,
+ displayName: "Ollama",
+ sessionLabel: "Session",
+ weeklyLabel: "Weekly",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Ollama usage",
+ cliName: "ollama",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder,
+ dashboardURL: "https://ollama.com/settings",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .ollama,
+ iconResourceName: "ProviderIcon-ollama",
+ color: ProviderColor(red: 32 / 255, green: 32 / 255, blue: 32 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Ollama cost summary is not supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OllamaStatusFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "ollama",
+ versionDetector: nil))
+ }
+}
+
+struct OllamaStatusFetchStrategy: ProviderFetchStrategy {
+ let id: String = "ollama.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ guard context.settings?.ollama?.cookieSource != .off else { return false }
+ return true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let fetcher = OllamaUsageFetcher(browserDetection: context.browserDetection)
+ let manual = Self.manualCookieHeader(from: context)
+ let logger: ((String) -> Void)? = context.verbose
+ ? { msg in CodexBarLog.logger(LogCategories.ollama).verbose(msg) }
+ : nil
+ let snap = try await fetcher.fetch(cookieHeaderOverride: manual, logger: logger)
+ return self.makeResult(
+ usage: snap.toUsageSnapshot(),
+ sourceLabel: "web")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func manualCookieHeader(from context: ProviderFetchContext) -> String? {
+ guard context.settings?.ollama?.cookieSource == .manual else { return nil }
+ return CookieHeaderNormalizer.normalize(context.settings?.ollama?.manualCookieHeader)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift
new file mode 100644
index 00000000..6971df99
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift
@@ -0,0 +1,380 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+#if os(macOS)
+import SweetCookieKit
+#endif
+
+public enum OllamaUsageError: LocalizedError, Sendable {
+ case notLoggedIn
+ case invalidCredentials
+ case parseFailed(String)
+ case networkError(String)
+ case noSessionCookie
+
+ public var errorDescription: String? {
+ switch self {
+ case .notLoggedIn:
+ "Not logged in to Ollama. Please log in via ollama.com/settings."
+ case .invalidCredentials:
+ "Ollama session cookie expired. Please log in again."
+ case let .parseFailed(message):
+ "Could not parse Ollama usage: \(message)"
+ case let .networkError(message):
+ "Ollama request failed: \(message)"
+ case .noSessionCookie:
+ "No Ollama session cookie found. Please log in to ollama.com in your browser."
+ }
+ }
+}
+
+#if os(macOS)
+private let ollamaCookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.ollama]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+public enum OllamaCookieImporter {
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["ollama.com", "www.ollama.com"]
+ private static let sessionCookieNames: Set = [
+ "session",
+ "ollama_session",
+ "__Host-ollama_session",
+ "__Secure-next-auth.session-token",
+ "next-auth.session-token",
+ ]
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public init(cookies: [HTTPCookie], sourceLabel: String) {
+ self.cookies = cookies
+ self.sourceLabel = sourceLabel
+ }
+
+ public var cookieHeader: String {
+ self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
+ }
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection,
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let log: (String) -> Void = { msg in logger?("[ollama-cookie] \(msg)") }
+ let installed = ollamaCookieImportOrder.cookieImportCandidates(using: browserDetection)
+ var fallback: SessionInfo?
+
+ for browserSource in installed {
+ 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 cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
+ guard !cookies.isEmpty else { continue }
+ let names = cookies.map(\.name).joined(separator: ", ")
+ log("\(source.label) cookies: \(names)")
+
+ let hasSessionCookie = cookies.contains { cookie in
+ if Self.sessionCookieNames.contains(cookie.name) { return true }
+ return cookie.name.lowercased().contains("session")
+ }
+
+ if hasSessionCookie {
+ log("Found Ollama session cookie in \(source.label)")
+ return SessionInfo(cookies: cookies, sourceLabel: source.label)
+ }
+
+ if fallback == nil {
+ fallback = SessionInfo(cookies: cookies, sourceLabel: source.label)
+ }
+
+ log("\(source.label) cookies found, but no recognized session cookie present")
+ }
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
+ }
+ }
+
+ if let fallback {
+ log("Using \(fallback.sourceLabel) cookies without a recognized session token")
+ return fallback
+ }
+
+ throw OllamaUsageError.noSessionCookie
+ }
+}
+#endif
+
+public struct OllamaUsageFetcher: Sendable {
+ private static let settingsURL = URL(string: "https://ollama.com/settings")!
+ @MainActor private static var recentDumps: [String] = []
+
+ public let browserDetection: BrowserDetection
+
+ public init(browserDetection: BrowserDetection) {
+ self.browserDetection = browserDetection
+ }
+
+ public func fetch(
+ cookieHeaderOverride: String? = nil,
+ logger: ((String) -> Void)? = nil,
+ now: Date = Date()) async throws -> OllamaUsageSnapshot
+ {
+ let log: (String) -> Void = { msg in logger?("[ollama] \(msg)") }
+ let cookieHeader = try await self.resolveCookieHeader(override: cookieHeaderOverride, logger: log)
+
+ if let logger {
+ let names = self.cookieNames(from: cookieHeader)
+ if !names.isEmpty {
+ logger("[ollama] Cookie names: \(names.joined(separator: ", "))")
+ }
+ let diagnostics = RedirectDiagnostics(cookieHeader: cookieHeader, logger: logger)
+ do {
+ let (html, responseInfo) = try await self.fetchHTMLWithDiagnostics(
+ cookieHeader: cookieHeader,
+ diagnostics: diagnostics)
+ self.logDiagnostics(responseInfo: responseInfo, diagnostics: diagnostics, logger: logger)
+ do {
+ return try OllamaUsageParser.parse(html: html, now: now)
+ } catch {
+ logger("[ollama] Parse failed: \(error.localizedDescription)")
+ self.logHTMLHints(html: html, logger: logger)
+ throw error
+ }
+ } catch {
+ self.logDiagnostics(responseInfo: nil, diagnostics: diagnostics, logger: logger)
+ throw error
+ }
+ }
+
+ let diagnostics = RedirectDiagnostics(cookieHeader: cookieHeader, logger: nil)
+ let (html, _) = try await self.fetchHTMLWithDiagnostics(
+ cookieHeader: cookieHeader,
+ diagnostics: diagnostics)
+ return try OllamaUsageParser.parse(html: html, now: now)
+ }
+
+ public func debugRawProbe(cookieHeaderOverride: String? = nil) async -> String {
+ let stamp = ISO8601DateFormatter().string(from: Date())
+ var lines: [String] = []
+ lines.append("=== Ollama Debug Probe @ \(stamp) ===")
+ lines.append("")
+
+ do {
+ let cookieHeader = try await self.resolveCookieHeader(
+ override: cookieHeaderOverride,
+ logger: { msg in lines.append("[cookie] \(msg)") })
+ let diagnostics = RedirectDiagnostics(cookieHeader: cookieHeader, logger: nil)
+ let cookieNames = CookieHeaderNormalizer.pairs(from: cookieHeader).map(\.name)
+ lines.append("Cookie names: \(cookieNames.joined(separator: ", "))")
+
+ let (snapshot, responseInfo) = try await self.fetchWithDiagnostics(
+ cookieHeader: cookieHeader,
+ diagnostics: diagnostics)
+
+ lines.append("")
+ lines.append("Fetch Success")
+ lines.append("Status: \(responseInfo.statusCode) \(responseInfo.url)")
+
+ if !diagnostics.redirects.isEmpty {
+ lines.append("")
+ lines.append("Redirects:")
+ for entry in diagnostics.redirects {
+ lines.append(" \(entry)")
+ }
+ }
+
+ lines.append("")
+ lines.append("Plan: \(snapshot.planName ?? "unknown")")
+ lines.append("Session: \(snapshot.sessionUsedPercent?.description ?? "nil")%")
+ lines.append("Weekly: \(snapshot.weeklyUsedPercent?.description ?? "nil")%")
+ lines.append("Session resetsAt: \(snapshot.sessionResetsAt?.description ?? "nil")")
+ lines.append("Weekly resetsAt: \(snapshot.weeklyResetsAt?.description ?? "nil")")
+
+ let output = lines.joined(separator: "\n")
+ Task { @MainActor in Self.recordDump(output) }
+ return output
+ } catch {
+ lines.append("")
+ lines.append("Probe Failed: \(error.localizedDescription)")
+ let output = lines.joined(separator: "\n")
+ Task { @MainActor in Self.recordDump(output) }
+ return output
+ }
+ }
+
+ public static func latestDumps() async -> String {
+ await MainActor.run {
+ let result = Self.recentDumps.joined(separator: "\n\n---\n\n")
+ return result.isEmpty ? "No Ollama probe dumps captured yet." : result
+ }
+ }
+
+ private func resolveCookieHeader(
+ override: String?,
+ logger: ((String) -> Void)?) async throws -> String
+ {
+ if let override = CookieHeaderNormalizer.normalize(override) {
+ if !override.isEmpty {
+ logger?("[ollama] Using manual cookie header")
+ return override
+ }
+ throw OllamaUsageError.noSessionCookie
+ }
+ #if os(macOS)
+ let session = try OllamaCookieImporter.importSession(browserDetection: self.browserDetection, logger: logger)
+ logger?("[ollama] Using cookies from \(session.sourceLabel)")
+ return session.cookieHeader
+ #else
+ throw OllamaUsageError.noSessionCookie
+ #endif
+ }
+
+ private func fetchWithDiagnostics(
+ cookieHeader: String,
+ diagnostics: RedirectDiagnostics,
+ now: Date = Date()) async throws -> (OllamaUsageSnapshot, ResponseInfo)
+ {
+ let (html, responseInfo) = try await self.fetchHTMLWithDiagnostics(
+ cookieHeader: cookieHeader,
+ diagnostics: diagnostics)
+ let snapshot = try OllamaUsageParser.parse(html: html, now: now)
+ return (snapshot, responseInfo)
+ }
+
+ private func fetchHTMLWithDiagnostics(
+ cookieHeader: String,
+ diagnostics: RedirectDiagnostics) async throws -> (String, ResponseInfo)
+ {
+ var request = URLRequest(url: Self.settingsURL)
+ request.httpMethod = "GET"
+ request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+ request.setValue(
+ "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ forHTTPHeaderField: "accept")
+ request.setValue(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
+ forHTTPHeaderField: "user-agent")
+ request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language")
+ request.setValue("https://ollama.com", forHTTPHeaderField: "origin")
+ request.setValue(Self.settingsURL.absoluteString, forHTTPHeaderField: "referer")
+
+ let session = URLSession(configuration: .ephemeral, delegate: diagnostics, delegateQueue: nil)
+ let (data, response) = try await session.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw OllamaUsageError.networkError("Invalid response")
+ }
+ let responseInfo = ResponseInfo(
+ statusCode: httpResponse.statusCode,
+ url: httpResponse.url?.absoluteString ?? "unknown")
+
+ guard httpResponse.statusCode == 200 else {
+ if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
+ throw OllamaUsageError.invalidCredentials
+ }
+ throw OllamaUsageError.networkError("HTTP \(httpResponse.statusCode)")
+ }
+
+ let html = String(data: data, encoding: .utf8) ?? ""
+ return (html, responseInfo)
+ }
+
+ @MainActor private static func recordDump(_ text: String) {
+ if self.recentDumps.count >= 5 { self.recentDumps.removeFirst() }
+ self.recentDumps.append(text)
+ }
+
+ private final class RedirectDiagnostics: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
+ private let cookieHeader: String
+ private let logger: ((String) -> Void)?
+ var redirects: [String] = []
+
+ init(cookieHeader: String, logger: ((String) -> Void)?) {
+ self.cookieHeader = cookieHeader
+ self.logger = logger
+ }
+
+ func urlSession(
+ _: URLSession,
+ task _: URLSessionTask,
+ willPerformHTTPRedirection response: HTTPURLResponse,
+ newRequest request: URLRequest,
+ completionHandler: @escaping (URLRequest?) -> Void)
+ {
+ let from = response.url?.absoluteString ?? "unknown"
+ let to = request.url?.absoluteString ?? "unknown"
+ self.redirects.append("\(response.statusCode) \(from) -> \(to)")
+ var updated = request
+ if OllamaUsageFetcher.shouldAttachCookie(to: request.url), !self.cookieHeader.isEmpty {
+ updated.setValue(self.cookieHeader, forHTTPHeaderField: "Cookie")
+ } else {
+ updated.setValue(nil, forHTTPHeaderField: "Cookie")
+ }
+ if let referer = response.url?.absoluteString {
+ updated.setValue(referer, forHTTPHeaderField: "referer")
+ }
+ if let logger {
+ logger("[ollama] Redirect \(response.statusCode) \(from) -> \(to)")
+ }
+ completionHandler(updated)
+ }
+ }
+
+ private struct ResponseInfo: Sendable {
+ let statusCode: Int
+ let url: String
+ }
+
+ private func logDiagnostics(
+ responseInfo: ResponseInfo?,
+ diagnostics: RedirectDiagnostics,
+ logger: (String) -> Void)
+ {
+ if let responseInfo {
+ logger("[ollama] Response: \(responseInfo.statusCode) \(responseInfo.url)")
+ }
+ if !diagnostics.redirects.isEmpty {
+ logger("[ollama] Redirects:")
+ for entry in diagnostics.redirects {
+ logger("[ollama] \(entry)")
+ }
+ }
+ }
+
+ private func logHTMLHints(html: String, logger: (String) -> Void) {
+ let trimmed = html
+ .replacingOccurrences(of: "\n", with: " ")
+ .replacingOccurrences(of: "\t", with: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ let snippet = trimmed.prefix(240)
+ logger("[ollama] HTML snippet: \(snippet)")
+ }
+ logger("[ollama] Contains Cloud Usage: \(html.contains("Cloud Usage"))")
+ logger("[ollama] Contains Session usage: \(html.contains("Session usage"))")
+ logger("[ollama] Contains Weekly usage: \(html.contains("Weekly usage"))")
+ }
+
+ private func cookieNames(from header: String) -> [String] {
+ header.split(separator: ";", omittingEmptySubsequences: false).compactMap { part in
+ let trimmed = part.trimmingCharacters(in: .whitespaces)
+ guard let idx = trimmed.firstIndex(of: "=") else { return nil }
+ let name = trimmed[.. Bool {
+ guard let host = url?.host?.lowercased() else { return false }
+ if host == "ollama.com" || host == "www.ollama.com" { return true }
+ return host.hasSuffix(".ollama.com")
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift
new file mode 100644
index 00000000..a1def6e8
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift
@@ -0,0 +1,114 @@
+import Foundation
+
+enum OllamaUsageParser {
+ static func parse(html: String, now: Date = Date()) throws -> OllamaUsageSnapshot {
+ let plan = self.parsePlanName(html)
+ let email = self.parseAccountEmail(html)
+ let session = self.parseUsageBlock(label: "Session usage", html: html)
+ let weekly = self.parseUsageBlock(label: "Weekly usage", html: html)
+
+ if session == nil && weekly == nil {
+ if self.looksSignedOut(html) {
+ throw OllamaUsageError.notLoggedIn
+ }
+ throw OllamaUsageError.parseFailed("Missing Ollama usage data.")
+ }
+
+ return OllamaUsageSnapshot(
+ planName: plan,
+ accountEmail: email,
+ sessionUsedPercent: session?.usedPercent,
+ weeklyUsedPercent: weekly?.usedPercent,
+ sessionResetsAt: session?.resetsAt,
+ weeklyResetsAt: weekly?.resetsAt,
+ updatedAt: now)
+ }
+
+ private struct UsageBlock: Sendable {
+ let usedPercent: Double
+ let resetsAt: Date?
+ }
+
+ private static func parsePlanName(_ html: String) -> String? {
+ let pattern = #"Cloud Usage\s*\s*]*>([^<]+)"#
+ guard let raw = self.firstCapture(in: html, pattern: pattern, options: [.dotMatchesLineSeparators])
+ else { return nil }
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private static func parseAccountEmail(_ html: String) -> String? {
+ let pattern = #"id=\"header-email\"[^>]*>([^<]+)<"#
+ guard let raw = self.firstCapture(in: html, pattern: pattern, options: [.dotMatchesLineSeparators])
+ else { return nil }
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.contains("@") else { return nil }
+ return trimmed
+ }
+
+ private static func parseUsageBlock(label: String, html: String) -> UsageBlock? {
+ guard let labelRange = html.range(of: label) else { return nil }
+ let tail = String(html[labelRange.upperBound...])
+ let window = String(tail.prefix(800))
+
+ guard let usedPercent = self.parsePercent(in: window) else { return nil }
+ let resetsAt = self.parseISODate(in: window)
+ return UsageBlock(usedPercent: usedPercent, resetsAt: resetsAt)
+ }
+
+ private static func parsePercent(in text: String) -> Double? {
+ let usedPattern = #"([0-9]+(?:\.[0-9]+)?)\s*%\s*used"#
+ if let raw = self.firstCapture(in: text, pattern: usedPattern, options: [.caseInsensitive]) {
+ return Double(raw)
+ }
+ let widthPattern = #"width:\s*([0-9]+(?:\.[0-9]+)?)%"#
+ if let raw = self.firstCapture(in: text, pattern: widthPattern, options: [.caseInsensitive]) {
+ return Double(raw)
+ }
+ return nil
+ }
+
+ private static func parseISODate(in text: String) -> Date? {
+ let pattern = #"data-time=\"([^\"]+)\""#
+ guard let raw = self.firstCapture(in: text, pattern: pattern, options: []) else { return nil }
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: raw) {
+ return date
+ }
+ let fallback = ISO8601DateFormatter()
+ fallback.formatOptions = [.withInternetDateTime]
+ return fallback.date(from: raw)
+ }
+
+ private static func firstCapture(
+ in text: String,
+ pattern: String,
+ options: NSRegularExpression.Options) -> String?
+ {
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { return nil }
+ return Self.performMatch(regex: regex, text: text)
+ }
+
+ private static func performMatch(
+ regex: NSRegularExpression,
+ text: String) -> String?
+ {
+ let range = NSRange(text.startIndex.. 1,
+ let captureRange = Range(match.range(at: 1), in: text)
+ else { return nil }
+ return String(text[captureRange])
+ }
+
+ private static func looksSignedOut(_ html: String) -> Bool {
+ let lower = html.lowercased()
+ if lower.contains("sign in") || lower.contains("log in") || lower.contains("login") {
+ return true
+ }
+ if lower.contains("/login") || lower.contains("/signin") {
+ return true
+ }
+ return false
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift
new file mode 100644
index 00000000..002f78d8
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift
@@ -0,0 +1,66 @@
+import Foundation
+
+public struct OllamaUsageSnapshot: Sendable {
+ public let planName: String?
+ public let accountEmail: String?
+ public let sessionUsedPercent: Double?
+ public let weeklyUsedPercent: Double?
+ public let sessionResetsAt: Date?
+ public let weeklyResetsAt: Date?
+ public let updatedAt: Date
+
+ public init(
+ planName: String?,
+ accountEmail: String?,
+ sessionUsedPercent: Double?,
+ weeklyUsedPercent: Double?,
+ sessionResetsAt: Date?,
+ weeklyResetsAt: Date?,
+ updatedAt: Date)
+ {
+ self.planName = planName
+ self.accountEmail = accountEmail
+ self.sessionUsedPercent = sessionUsedPercent
+ self.weeklyUsedPercent = weeklyUsedPercent
+ self.sessionResetsAt = sessionResetsAt
+ self.weeklyResetsAt = weeklyResetsAt
+ self.updatedAt = updatedAt
+ }
+}
+
+extension OllamaUsageSnapshot {
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let sessionWindow = self.makeWindow(
+ usedPercent: self.sessionUsedPercent,
+ resetsAt: self.sessionResetsAt)
+ let weeklyWindow = self.makeWindow(
+ usedPercent: self.weeklyUsedPercent,
+ resetsAt: self.weeklyResetsAt)
+
+ let plan = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let email = self.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let identity = ProviderIdentitySnapshot(
+ providerID: .ollama,
+ accountEmail: email?.isEmpty == false ? email : nil,
+ accountOrganization: nil,
+ loginMethod: plan?.isEmpty == false ? plan : nil)
+
+ return UsageSnapshot(
+ primary: sessionWindow,
+ secondary: weeklyWindow,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+
+ private func makeWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? {
+ guard let usedPercent else { return nil }
+ let clamped = min(100, max(0, usedPercent))
+ return RateWindow(
+ usedPercent: clamped,
+ windowMinutes: nil,
+ resetsAt: resetsAt,
+ resetDescription: nil)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 6aff8369..99b529a7 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -70,6 +70,7 @@ public enum ProviderDescriptorRegistry {
.jetbrains: JetBrainsProviderDescriptor.descriptor,
.kimik2: KimiK2ProviderDescriptor.descriptor,
.amp: AmpProviderDescriptor.descriptor,
+ .ollama: OllamaProviderDescriptor.descriptor,
.synthetic: SyntheticProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index f83cb9fd..a6fe9d61 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -15,6 +15,7 @@ public struct ProviderSettingsSnapshot: Sendable {
kimi: KimiProviderSettings? = nil,
augment: AugmentProviderSettings? = nil,
amp: AmpProviderSettings? = nil,
+ ollama: OllamaProviderSettings? = nil,
jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
@@ -31,6 +32,7 @@ public struct ProviderSettingsSnapshot: Sendable {
kimi: kimi,
augment: augment,
amp: amp,
+ ollama: ollama,
jetbrains: jetbrains)
}
@@ -167,6 +169,16 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct OllamaProviderSettings: Sendable {
+ public let cookieSource: ProviderCookieSource
+ public let manualCookieHeader: String?
+
+ public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) {
+ self.cookieSource = cookieSource
+ self.manualCookieHeader = manualCookieHeader
+ }
+ }
+
public let debugMenuEnabled: Bool
public let debugKeepCLISessionsAlive: Bool
public let codex: CodexProviderSettings?
@@ -180,6 +192,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let kimi: KimiProviderSettings?
public let augment: AugmentProviderSettings?
public let amp: AmpProviderSettings?
+ public let ollama: OllamaProviderSettings?
public let jetbrains: JetBrainsProviderSettings?
public var jetbrainsIDEBasePath: String? {
@@ -200,6 +213,7 @@ public struct ProviderSettingsSnapshot: Sendable {
kimi: KimiProviderSettings?,
augment: AugmentProviderSettings?,
amp: AmpProviderSettings?,
+ ollama: OllamaProviderSettings?,
jetbrains: JetBrainsProviderSettings? = nil)
{
self.debugMenuEnabled = debugMenuEnabled
@@ -215,6 +229,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.kimi = kimi
self.augment = augment
self.amp = amp
+ self.ollama = ollama
self.jetbrains = jetbrains
}
}
@@ -231,6 +246,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case kimi(ProviderSettingsSnapshot.KimiProviderSettings)
case augment(ProviderSettingsSnapshot.AugmentProviderSettings)
case amp(ProviderSettingsSnapshot.AmpProviderSettings)
+ case ollama(ProviderSettingsSnapshot.OllamaProviderSettings)
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
}
@@ -248,6 +264,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var kimi: ProviderSettingsSnapshot.KimiProviderSettings?
public var augment: ProviderSettingsSnapshot.AugmentProviderSettings?
public var amp: ProviderSettingsSnapshot.AmpProviderSettings?
+ public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings?
public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings?
public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) {
@@ -268,6 +285,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .kimi(value): self.kimi = value
case let .augment(value): self.augment = value
case let .amp(value): self.amp = value
+ case let .ollama(value): self.ollama = value
case let .jetbrains(value): self.jetbrains = value
}
}
@@ -287,6 +305,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
kimi: self.kimi,
augment: self.augment,
amp: self.amp,
+ ollama: self.ollama,
jetbrains: self.jetbrains)
}
}
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index a267fb95..3cf0192a 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -20,6 +20,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case jetbrains
case kimik2
case amp
+ case ollama
case synthetic
}
@@ -43,6 +44,7 @@ public enum IconStyle: Sendable, CaseIterable {
case augment
case jetbrains
case amp
+ case ollama
case synthetic
case combined
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index c50b106c..7586699f 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -97,6 +97,8 @@ enum CostUsageScanner {
return CostUsageDailyReport(data: [], summary: nil)
case .amp:
return CostUsageDailyReport(data: [], summary: nil)
+ case .ollama:
+ return CostUsageDailyReport(data: [], summary: nil)
case .synthetic:
return CostUsageDailyReport(data: [], summary: nil)
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 1634611e..5164a821 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -58,6 +58,7 @@ enum ProviderChoice: String, AppEnum {
case .kimi: return nil // Kimi not yet supported in widgets
case .kimik2: return nil // Kimi K2 not yet supported in widgets
case .amp: return nil // Amp not yet supported in widgets
+ case .ollama: return nil // Ollama not yet supported in widgets
case .synthetic: return nil // Synthetic not yet supported in widgets
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index ed39b450..6de2f543 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -274,6 +274,7 @@ private struct ProviderSwitchChip: View {
case .kimi: "Kimi"
case .kimik2: "Kimi K2"
case .amp: "Amp"
+ case .ollama: "Ollama"
case .synthetic: "Synthetic"
}
}
@@ -603,6 +604,8 @@ enum WidgetColors {
Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple
case .amp:
Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red
+ case .ollama:
+ Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal
case .synthetic:
Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal
}
diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift
index ec855180..de3a9f29 100644
--- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift
+++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift
@@ -20,6 +20,7 @@ struct CLIProviderSelectionTests {
"|copilot|",
"|synthetic|",
"|kiro|",
+ "|ollama|",
"|both|",
"|all]",
]
diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift
new file mode 100644
index 00000000..c520e8cd
--- /dev/null
+++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift
@@ -0,0 +1,20 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite
+struct OllamaUsageFetcherTests {
+ @Test
+ func attachesCookieForOllamaHosts() {
+ #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com/settings")))
+ #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://www.ollama.com")))
+ #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://app.ollama.com/path")))
+ }
+
+ @Test
+ func rejectsNonOllamaHosts() {
+ #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://example.com")))
+ #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com.evil.com")))
+ #expect(!OllamaUsageFetcher.shouldAttachCookie(to: nil))
+ }
+}
diff --git a/Tests/CodexBarTests/OllamaUsageParserTests.swift b/Tests/CodexBarTests/OllamaUsageParserTests.swift
new file mode 100644
index 00000000..01d81dcb
--- /dev/null
+++ b/Tests/CodexBarTests/OllamaUsageParserTests.swift
@@ -0,0 +1,92 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite
+struct OllamaUsageParserTests {
+ @Test
+ func parsesCloudUsageFromSettingsHTML() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let html = """
+
+
+ Cloud Usage
+ free
+
+
+
+
Session usage
+
0.1% used
+
Resets in 3 hours
+
+
+
Weekly usage
+
0.7% used
+
Resets in 2 days
+
+
+ """
+
+ let snapshot = try OllamaUsageParser.parse(html: html, now: now)
+
+ #expect(snapshot.planName == "free")
+ #expect(snapshot.accountEmail == "user@example.com")
+ #expect(snapshot.sessionUsedPercent == 0.1)
+ #expect(snapshot.weeklyUsedPercent == 0.7)
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ let expectedSession = formatter.date(from: "2026-01-30T18:00:00Z")
+ let expectedWeekly = formatter.date(from: "2026-02-02T00:00:00Z")
+ #expect(snapshot.sessionResetsAt == expectedSession)
+ #expect(snapshot.weeklyResetsAt == expectedWeekly)
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.identity?.loginMethod == "free")
+ #expect(usage.identity?.accountEmail == "user@example.com")
+ }
+
+ @Test
+ func missingUsageThrowsParseFailed() {
+ let html = "No usage here."
+
+ #expect {
+ try OllamaUsageParser.parse(html: html)
+ } throws: { error in
+ guard case let OllamaUsageError.parseFailed(message) = error else { return false }
+ return message.contains("Missing Ollama usage data")
+ }
+ }
+
+ @Test
+ func signedOutThrowsNotLoggedIn() {
+ let html = "Please sign in to Ollama."
+
+ #expect {
+ try OllamaUsageParser.parse(html: html)
+ } throws: { error in
+ guard case OllamaUsageError.notLoggedIn = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func parsesUsageWhenUsedIsCapitalized() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let html = """
+
+
Session usage
+
1.2% Used
+
Resets in 3 hours
+
Weekly usage
+
3.4% USED
+
Resets in 2 days
+
+ """
+
+ let snapshot = try OllamaUsageParser.parse(html: html, now: now)
+
+ #expect(snapshot.sessionUsedPercent == 1.2)
+ #expect(snapshot.weeklyUsedPercent == 3.4)
+ }
+}
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index 108d31bd..37747cb4 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -363,6 +363,7 @@ struct SettingsStoreTests {
.jetbrains,
.kimik2,
.amp,
+ .ollama,
.synthetic,
])
diff --git a/docs/ollama.md b/docs/ollama.md
new file mode 100644
index 00000000..88233879
--- /dev/null
+++ b/docs/ollama.md
@@ -0,0 +1,52 @@
+---
+summary: "Ollama provider notes: settings scrape, cookie auth, and Cloud Usage parsing."
+read_when:
+ - Adding or modifying the Ollama provider
+ - Debugging Ollama cookie import or settings parsing
+ - Adjusting Ollama menu labels or usage mapping
+---
+
+# Ollama Provider
+
+The Ollama provider scrapes the **Plan & Billing** page to extract Cloud Usage limits for session and weekly windows.
+
+## Features
+
+- **Plan badge**: Reads the plan tier (Free/Pro/Max) from the Cloud Usage header.
+- **Session + weekly usage**: Parses the percent-used values shown in the usage bars.
+- **Reset timestamps**: Uses the `data-time` attribute on the “Resets in …” elements.
+- **Browser cookie auth**: No API keys required.
+
+## Setup
+
+1. Open **Settings → Providers**.
+2. Enable **Ollama**.
+3. Leave **Cookie source** on **Auto** (recommended).
+
+### Manual cookie import (optional)
+
+1. Open `https://ollama.com/settings` in your browser.
+2. Copy a `Cookie:` header from the Network tab.
+3. Paste it into **Ollama → Cookie source → Manual**.
+
+## How it works
+
+- Fetches `https://ollama.com/settings` using browser cookies.
+- Parses:
+ - Plan badge under **Cloud Usage**.
+ - **Session usage** and **Weekly usage** percentages.
+ - `data-time` ISO timestamps for reset times.
+
+## Troubleshooting
+
+### “No Ollama session cookie found”
+
+Log in to `https://ollama.com/settings` in a supported browser (Safari or Chromium-based), then refresh in CodexBar.
+
+### “Ollama session cookie expired”
+
+Sign out and back in at `https://ollama.com/settings`, then refresh.
+
+### “Could not parse Ollama usage”
+
+The settings page HTML may have changed. Capture the latest page HTML and update `OllamaUsageParser`.
diff --git a/docs/providers.md b/docs/providers.md
index d25dfb88..ffdd4672 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, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Vertex AI, Augment, Amp, JetBrains AI)."
+summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Vertex AI, Augment, Amp, Ollama, JetBrains AI)."
read_when:
- Adding or modifying provider fetch/parsing
- Adjusting provider labels, toggles, or metadata
@@ -34,6 +34,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Vertex AI | Google ADC OAuth (gcloud) → Cloud Monitoring quota usage (`oauth`). |
| JetBrains AI | Local XML quota file (`local`). |
| Amp | Web settings page via browser cookies (`web`). |
+| Ollama | Web settings page via browser cookies (`web`). |
## Codex
- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -139,4 +140,11 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Parses Amp Free usage from the settings HTML.
- Status: none yet.
- Details: `docs/amp.md`.
+
+## Ollama
+- Web settings page (`https://ollama.com/settings`) via browser cookies.
+- Parses Cloud Usage plan badge, session/weekly usage, and reset timestamps.
+- Status: none yet.
+- Details: `docs/ollama.md`.
+
See also: `docs/provider.md` for architecture notes.