From f2821201a9809e64a1c78ed863b9f8ed864a32b5 Mon Sep 17 00:00:00 2001 From: CryptoSageSnr Date: Sun, 1 Feb 2026 15:30:31 +0200 Subject: [PATCH] Add Ollama provider support --- .gitignore | 1 + Sources/CodexBar/PreferencesDebugPane.swift | 2 + .../Ollama/OllamaProviderImplementation.swift | 81 ++++ .../Ollama/OllamaSettingsStore.swift | 60 +++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-ollama.svg | 3 + .../SettingsStore+MenuObservation.swift | 2 + Sources/CodexBar/UsageStore+Logging.swift | 1 + Sources/CodexBar/UsageStore.swift | 19 + Sources/CodexBarCLI/TokenAccountCLI.swift | 7 + .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Ollama/OllamaProviderDescriptor.swift | 72 ++++ .../Providers/Ollama/OllamaUsageFetcher.swift | 380 ++++++++++++++++++ .../Providers/Ollama/OllamaUsageParser.swift | 114 ++++++ .../Ollama/OllamaUsageSnapshot.swift | 66 +++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 19 + .../CodexBarCore/Providers/Providers.swift | 2 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 + .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../CLIProviderSelectionTests.swift | 1 + .../OllamaUsageFetcherTests.swift | 20 + .../OllamaUsageParserTests.swift | 92 +++++ Tests/CodexBarTests/SettingsStoreTests.swift | 1 + docs/ollama.md | 52 +++ docs/providers.md | 10 +- 27 files changed, 1013 insertions(+), 1 deletion(-) create mode 100644 Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-ollama.svg create mode 100644 Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift create mode 100644 Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift create mode 100644 Tests/CodexBarTests/OllamaUsageFetcherTests.swift create mode 100644 Tests/CodexBarTests/OllamaUsageParserTests.swift create mode 100644 docs/ollama.md 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 +

+

user@example.com

+
+ 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.