845 lines
33 KiB
Swift
845 lines
33 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
import SweetCookieKit
|
|
#endif
|
|
|
|
private let ollamaSessionCookieNames: Set<String> = [
|
|
"session",
|
|
"__Secure-session",
|
|
"ollama_session",
|
|
"__Host-ollama_session",
|
|
"wos-session",
|
|
"__Secure-next-auth.session-token",
|
|
"next-auth.session-token",
|
|
]
|
|
|
|
private func isRecognizedOllamaSessionCookieName(_ name: String) -> Bool {
|
|
if ollamaSessionCookieNames.contains(name) { return true }
|
|
// next-auth can split tokens into chunked cookies: `<name>.0`, `<name>.1`, ...
|
|
return name.hasPrefix("__Secure-next-auth.session-token.") ||
|
|
name.hasPrefix("next-auth.session-token.")
|
|
}
|
|
|
|
private func hasRecognizedOllamaSessionCookie(in header: String) -> Bool {
|
|
CookieHeaderNormalizer.pairs(from: header).contains { pair in
|
|
isRecognizedOllamaSessionCookieName(pair.name)
|
|
}
|
|
}
|
|
|
|
public enum OllamaUsageError: LocalizedError, Sendable {
|
|
private static let signInURL = "https://ollama.com/signin"
|
|
|
|
case missingAPIKey
|
|
case notLoggedIn
|
|
case invalidCredentials
|
|
case apiUnauthorized
|
|
case parseFailed(String)
|
|
case networkError(String)
|
|
case noSessionCookie
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .missingAPIKey:
|
|
"Missing Ollama API key. Set apiKey in ~/.codexbar/config.json or OLLAMA_API_KEY."
|
|
case .notLoggedIn:
|
|
"Not signed in to Ollama. Please sign in at \(Self.signInURL)."
|
|
case .invalidCredentials:
|
|
"Ollama session cookie expired. Please sign in again at \(Self.signInURL)."
|
|
case .apiUnauthorized:
|
|
"Ollama API key is invalid or revoked."
|
|
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 sign in at \(Self.signInURL) 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"]
|
|
static let defaultPreferredBrowsers: [Browser] = [.chrome]
|
|
static let defaultAllowFallbackBrowsers = true
|
|
|
|
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 importSessions(
|
|
browserDetection: BrowserDetection,
|
|
preferredBrowsers: [Browser] = [.chrome],
|
|
allowFallbackBrowsers: Bool = false,
|
|
logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
|
|
{
|
|
let log: (String) -> Void = { msg in logger?("[ollama-cookie] \(msg)") }
|
|
let preferredSources = preferredBrowsers.isEmpty
|
|
? ollamaCookieImportOrder.cookieImportCandidates(using: browserDetection)
|
|
: preferredBrowsers.cookieImportCandidates(using: browserDetection)
|
|
let preferredCandidates = self.collectSessionInfo(from: preferredSources, logger: log)
|
|
return try self.selectSessionInfosWithFallback(
|
|
preferredCandidates: preferredCandidates,
|
|
allowFallbackBrowsers: allowFallbackBrowsers,
|
|
loadFallbackCandidates: {
|
|
guard !preferredBrowsers.isEmpty else { return [] }
|
|
let fallbackSources = self.fallbackBrowserSources(
|
|
browserDetection: browserDetection,
|
|
excluding: preferredSources)
|
|
guard !fallbackSources.isEmpty else { return [] }
|
|
log("No recognized Ollama session in preferred browsers; trying fallback import order")
|
|
return self.collectSessionInfo(from: fallbackSources, logger: log)
|
|
},
|
|
logger: log)
|
|
}
|
|
|
|
public static func importSession(
|
|
browserDetection: BrowserDetection,
|
|
preferredBrowsers: [Browser] = [.chrome],
|
|
allowFallbackBrowsers: Bool = false,
|
|
logger: ((String) -> Void)? = nil) throws -> SessionInfo
|
|
{
|
|
let sessions = try self.importSessions(
|
|
browserDetection: browserDetection,
|
|
preferredBrowsers: preferredBrowsers,
|
|
allowFallbackBrowsers: allowFallbackBrowsers,
|
|
logger: logger)
|
|
guard let first = sessions.first else {
|
|
throw OllamaUsageError.noSessionCookie
|
|
}
|
|
return first
|
|
}
|
|
|
|
static func selectSessionInfos(
|
|
from candidates: [SessionInfo],
|
|
logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
|
|
{
|
|
var recognized: [SessionInfo] = []
|
|
for candidate in candidates {
|
|
let names = candidate.cookies.map(\.name).joined(separator: ", ")
|
|
logger?("\(candidate.sourceLabel) cookies: \(names)")
|
|
if self.containsRecognizedSessionCookie(in: candidate.cookies) {
|
|
logger?("Found Ollama session cookie in \(candidate.sourceLabel)")
|
|
recognized.append(candidate)
|
|
} else {
|
|
logger?("\(candidate.sourceLabel) cookies found, but no recognized session cookie present")
|
|
}
|
|
}
|
|
guard !recognized.isEmpty else {
|
|
throw OllamaUsageError.noSessionCookie
|
|
}
|
|
return recognized
|
|
}
|
|
|
|
static func selectSessionInfo(
|
|
from candidates: [SessionInfo],
|
|
logger: ((String) -> Void)? = nil) throws -> SessionInfo
|
|
{
|
|
guard let first = try self.selectSessionInfos(from: candidates, logger: logger).first else {
|
|
throw OllamaUsageError.noSessionCookie
|
|
}
|
|
return first
|
|
}
|
|
|
|
static func selectSessionInfosWithFallback(
|
|
preferredCandidates: [SessionInfo],
|
|
allowFallbackBrowsers: Bool,
|
|
loadFallbackCandidates: () -> [SessionInfo],
|
|
logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
|
|
{
|
|
guard allowFallbackBrowsers else {
|
|
return try self.selectSessionInfos(from: preferredCandidates, logger: logger)
|
|
}
|
|
do {
|
|
return try self.selectSessionInfos(from: preferredCandidates, logger: logger)
|
|
} catch OllamaUsageError.noSessionCookie {
|
|
let fallbackCandidates = loadFallbackCandidates()
|
|
return try self.selectSessionInfos(from: fallbackCandidates, logger: logger)
|
|
}
|
|
}
|
|
|
|
static func selectSessionInfoWithFallback(
|
|
preferredCandidates: [SessionInfo],
|
|
allowFallbackBrowsers: Bool,
|
|
loadFallbackCandidates: () -> [SessionInfo],
|
|
logger: ((String) -> Void)? = nil) throws -> SessionInfo
|
|
{
|
|
guard let first = try self.selectSessionInfosWithFallback(
|
|
preferredCandidates: preferredCandidates,
|
|
allowFallbackBrowsers: allowFallbackBrowsers,
|
|
loadFallbackCandidates: loadFallbackCandidates,
|
|
logger: logger).first
|
|
else {
|
|
throw OllamaUsageError.noSessionCookie
|
|
}
|
|
return first
|
|
}
|
|
|
|
private static func fallbackBrowserSources(
|
|
browserDetection: BrowserDetection,
|
|
excluding triedSources: [Browser]) -> [Browser]
|
|
{
|
|
let tried = Set(triedSources)
|
|
return ollamaCookieImportOrder.cookieImportCandidates(using: browserDetection)
|
|
.filter { !tried.contains($0) }
|
|
}
|
|
|
|
private static func collectSessionInfo(
|
|
from browserSources: [Browser],
|
|
logger: @escaping (String) -> Void) -> [SessionInfo]
|
|
{
|
|
var candidates: [SessionInfo] = []
|
|
for browserSource in browserSources {
|
|
do {
|
|
let query = BrowserCookieQuery(domains: self.cookieDomains)
|
|
let sources = try Self.cookieClient.codexBarRecords(
|
|
matching: query,
|
|
in: browserSource,
|
|
logger: logger)
|
|
for source in sources where !source.records.isEmpty {
|
|
let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
|
|
guard !cookies.isEmpty else { continue }
|
|
candidates.append(SessionInfo(cookies: cookies, sourceLabel: source.label))
|
|
}
|
|
} catch {
|
|
BrowserCookieAccessGate.recordIfNeeded(error)
|
|
logger("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
private static func containsRecognizedSessionCookie(in cookies: [HTTPCookie]) -> Bool {
|
|
cookies.contains { cookie in
|
|
isRecognizedOllamaSessionCookieName(cookie.name)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
public struct OllamaUsageFetcher: Sendable {
|
|
private static let settingsURL = URL(string: "https://ollama.com/settings")!
|
|
@MainActor private static var recentDumps: [String] = []
|
|
|
|
private struct CookieCandidate {
|
|
let cookieHeader: String
|
|
let sourceLabel: String
|
|
}
|
|
|
|
enum RetryableParseFailure: Error {
|
|
case missingUsageData
|
|
}
|
|
|
|
public let browserDetection: BrowserDetection
|
|
private let makeURLSession: @Sendable (URLSessionTaskDelegate?) -> URLSession
|
|
|
|
public init(browserDetection: BrowserDetection) {
|
|
self.browserDetection = browserDetection
|
|
self.makeURLSession = { delegate in
|
|
URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
|
|
}
|
|
}
|
|
|
|
init(
|
|
browserDetection: BrowserDetection,
|
|
makeURLSession: @escaping @Sendable (URLSessionTaskDelegate?) -> URLSession)
|
|
{
|
|
self.browserDetection = browserDetection
|
|
self.makeURLSession = makeURLSession
|
|
}
|
|
|
|
public func fetch(
|
|
cookieHeaderOverride: String? = nil,
|
|
manualCookieMode: Bool = false,
|
|
logger: ((String) -> Void)? = nil,
|
|
now: Date = Date()) async throws -> OllamaUsageSnapshot
|
|
{
|
|
let cookieCandidates = try await self.resolveCookieCandidates(
|
|
override: cookieHeaderOverride,
|
|
manualCookieMode: manualCookieMode,
|
|
logger: logger)
|
|
return try await self.fetchUsingCookieCandidates(
|
|
cookieCandidates,
|
|
logger: logger,
|
|
now: now)
|
|
}
|
|
|
|
static func shouldRetryWithNextCookieCandidate(after error: Error) -> Bool {
|
|
switch error {
|
|
case OllamaUsageError.invalidCredentials, OllamaUsageError.notLoggedIn:
|
|
true
|
|
case RetryableParseFailure.missingUsageData:
|
|
true
|
|
default:
|
|
false
|
|
}
|
|
}
|
|
|
|
private func fetchUsingCookieCandidates(
|
|
_ candidates: [CookieCandidate],
|
|
logger: ((String) -> Void)?,
|
|
now: Date) async throws -> OllamaUsageSnapshot
|
|
{
|
|
do {
|
|
return try await ProviderCandidateRetryRunner.run(
|
|
candidates,
|
|
shouldRetry: { error in
|
|
Self.shouldRetryWithNextCookieCandidate(after: error)
|
|
},
|
|
onRetry: { candidate, _ in
|
|
logger?("[ollama] Auth failed for \(candidate.sourceLabel); trying next cookie candidate")
|
|
},
|
|
attempt: { candidate in
|
|
logger?("[ollama] Using cookies from \(candidate.sourceLabel)")
|
|
let names = self.cookieNames(from: candidate.cookieHeader)
|
|
if !names.isEmpty {
|
|
logger?("[ollama] Cookie names: \(names.joined(separator: ", "))")
|
|
}
|
|
|
|
let diagnostics = RedirectDiagnostics(cookieHeader: candidate.cookieHeader, logger: logger)
|
|
do {
|
|
let (html, responseInfo) = try await self.fetchHTMLWithDiagnostics(
|
|
cookieHeader: candidate.cookieHeader,
|
|
diagnostics: diagnostics)
|
|
if let logger {
|
|
self.logDiagnostics(responseInfo: responseInfo, diagnostics: diagnostics, logger: logger)
|
|
}
|
|
do {
|
|
return try Self.parseSnapshotForRetry(html: html, now: now)
|
|
} catch {
|
|
let surfacedError = Self.surfacedError(from: error)
|
|
if let logger {
|
|
logger("[ollama] Parse failed: \(surfacedError.localizedDescription)")
|
|
self.logHTMLHints(html: html, logger: logger)
|
|
}
|
|
throw error
|
|
}
|
|
} catch {
|
|
if let logger {
|
|
self.logDiagnostics(responseInfo: nil, diagnostics: diagnostics, logger: logger)
|
|
}
|
|
throw error
|
|
}
|
|
})
|
|
} catch ProviderCandidateRetryRunnerError.noCandidates {
|
|
throw OllamaUsageError.noSessionCookie
|
|
} catch {
|
|
throw Self.surfacedError(from: error)
|
|
}
|
|
}
|
|
|
|
private static func parseSnapshotForRetry(html: String, now: Date) throws -> OllamaUsageSnapshot {
|
|
switch OllamaUsageParser.parseClassified(html: html, now: now) {
|
|
case let .success(snapshot):
|
|
return snapshot
|
|
case .failure(.notLoggedIn):
|
|
throw OllamaUsageError.notLoggedIn
|
|
case .failure(.missingUsageData):
|
|
throw RetryableParseFailure.missingUsageData
|
|
}
|
|
}
|
|
|
|
private static func surfacedError(from error: Error) -> Error {
|
|
switch error {
|
|
case RetryableParseFailure.missingUsageData:
|
|
OllamaUsageError.parseFailed("Missing Ollama usage data.")
|
|
default:
|
|
error
|
|
}
|
|
}
|
|
|
|
private func resolveCookieCandidates(
|
|
override: String?,
|
|
manualCookieMode: Bool,
|
|
logger: ((String) -> Void)?) async throws -> [CookieCandidate]
|
|
{
|
|
if let manualHeader = try Self.resolveManualCookieHeader(
|
|
override: override,
|
|
manualCookieMode: manualCookieMode,
|
|
logger: logger)
|
|
{
|
|
return [CookieCandidate(cookieHeader: manualHeader, sourceLabel: "manual cookie header")]
|
|
}
|
|
#if os(macOS)
|
|
let sessions = try OllamaCookieImporter.importSessions(
|
|
browserDetection: self.browserDetection,
|
|
preferredBrowsers: OllamaCookieImporter.defaultPreferredBrowsers,
|
|
allowFallbackBrowsers: OllamaCookieImporter.defaultAllowFallbackBrowsers,
|
|
logger: logger)
|
|
return sessions.map { session in
|
|
CookieCandidate(cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel)
|
|
}
|
|
#else
|
|
throw OllamaUsageError.noSessionCookie
|
|
#endif
|
|
}
|
|
|
|
public func debugRawProbe(
|
|
cookieHeaderOverride: String? = nil,
|
|
manualCookieMode: Bool = false) 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,
|
|
manualCookieMode: manualCookieMode,
|
|
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?,
|
|
manualCookieMode: Bool,
|
|
logger: ((String) -> Void)?) async throws -> String
|
|
{
|
|
if let manualHeader = try Self.resolveManualCookieHeader(
|
|
override: override,
|
|
manualCookieMode: manualCookieMode,
|
|
logger: logger)
|
|
{
|
|
return manualHeader
|
|
}
|
|
#if os(macOS)
|
|
let session = try OllamaCookieImporter.importSession(
|
|
browserDetection: self.browserDetection,
|
|
preferredBrowsers: OllamaCookieImporter.defaultPreferredBrowsers,
|
|
allowFallbackBrowsers: OllamaCookieImporter.defaultAllowFallbackBrowsers,
|
|
logger: logger)
|
|
logger?("[ollama] Using cookies from \(session.sourceLabel)")
|
|
return session.cookieHeader
|
|
#else
|
|
throw OllamaUsageError.noSessionCookie
|
|
#endif
|
|
}
|
|
|
|
static func resolveManualCookieHeader(
|
|
override: String?,
|
|
manualCookieMode: Bool,
|
|
logger: ((String) -> Void)? = nil) throws -> String?
|
|
{
|
|
if let override = CookieHeaderNormalizer.normalize(override) {
|
|
guard hasRecognizedOllamaSessionCookie(in: override) else {
|
|
logger?("[ollama] Manual cookie header missing recognized session cookie")
|
|
throw OllamaUsageError.noSessionCookie
|
|
}
|
|
logger?("[ollama] Using manual cookie header")
|
|
return override
|
|
}
|
|
if manualCookieMode {
|
|
throw OllamaUsageError.noSessionCookie
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 = self.makeURLSession(diagnostics)
|
|
let httpResponse = try await session.response(for: request)
|
|
let responseInfo = ResponseInfo(
|
|
statusCode: httpResponse.statusCode,
|
|
url: httpResponse.response.url?.absoluteString ?? "unknown")
|
|
|
|
if httpResponse.statusCode == 200, Self.isSignInRedirect(httpResponse.response.url) {
|
|
throw OllamaUsageError.invalidCredentials
|
|
}
|
|
|
|
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: httpResponse.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 {
|
|
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) {
|
|
logger("[ollama] HTML length: \(html.utf8.count) bytes")
|
|
logger("[ollama] Contains Cloud Usage: \(html.contains("Cloud Usage"))")
|
|
logger("[ollama] Contains Session usage: \(html.contains("Session usage"))")
|
|
logger("[ollama] Contains Hourly usage: \(html.contains("Hourly 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[..<idx]
|
|
return name.isEmpty ? nil : String(name)
|
|
}
|
|
}
|
|
|
|
static func shouldAttachCookie(to url: URL?) -> Bool {
|
|
guard url?.scheme?.lowercased() == "https" else { return false }
|
|
guard let host = url?.host?.lowercased() else { return false }
|
|
if host == "ollama.com" || host == "www.ollama.com" { return true }
|
|
return host.hasSuffix(".ollama.com")
|
|
}
|
|
|
|
static func isSignInRedirect(_ url: URL?) -> Bool {
|
|
guard url?.scheme?.lowercased() == "https" else { return false }
|
|
guard let url, let host = url.host?.lowercased() else { return false }
|
|
let path = url.path.lowercased()
|
|
if host == "ollama.com" || host == "www.ollama.com" {
|
|
return path == "/signin"
|
|
}
|
|
// WorkOS AuthKit ultimately bounces unauthenticated requests to a hosted
|
|
// Ollama sign-in page on the `signin.ollama.com` subdomain; any landing
|
|
// there means the session is expired and the user must sign in again.
|
|
if host == "signin.ollama.com" {
|
|
return true
|
|
}
|
|
// WorkOS AuthKit serves the hosted authorization flow from auth.workos.com
|
|
// (and historically api.workos.com); match any WorkOS host carrying the
|
|
// authorize path so the detection survives host changes or CNAMEs.
|
|
return host.hasSuffix(".workos.com") && path.hasPrefix("/user_management/authorize")
|
|
}
|
|
}
|
|
|
|
public struct OllamaAPISettingsReader: Sendable {
|
|
public static let apiKeyEnvironmentKeys = [
|
|
"OLLAMA_API_KEY",
|
|
"OLLAMA_KEY",
|
|
]
|
|
|
|
public static func apiKey(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
|
|
{
|
|
for key in self.apiKeyEnvironmentKeys {
|
|
guard let value = self.cleaned(environment[key]), !value.isEmpty else { continue }
|
|
return value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func cleaned(_ raw: String?) -> String? {
|
|
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!value.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
|
|
(value.hasPrefix("'") && value.hasSuffix("'"))
|
|
{
|
|
value = String(value.dropFirst().dropLast())
|
|
}
|
|
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|
|
|
|
public struct OllamaAPIUsageSnapshot: Sendable {
|
|
public let modelCount: Int
|
|
public let updatedAt: Date
|
|
|
|
public init(modelCount: Int, updatedAt: Date) {
|
|
self.modelCount = modelCount
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
public func toUsageSnapshot() -> UsageSnapshot {
|
|
UsageSnapshot(
|
|
primary: nil,
|
|
secondary: nil,
|
|
tertiary: nil,
|
|
providerCost: nil,
|
|
updatedAt: self.updatedAt,
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .ollama,
|
|
accountEmail: nil,
|
|
accountOrganization: nil,
|
|
loginMethod: "API key"))
|
|
}
|
|
}
|
|
|
|
public enum OllamaAPIUsageFetcher {
|
|
public static let tagsURL = URL(string: "https://ollama.com/api/tags")!
|
|
public static let validationURL = URL(string: "https://ollama.com/api/web_search")!
|
|
private static let timeoutSeconds: TimeInterval = 20
|
|
|
|
public static func fetchUsage(
|
|
apiKey: String,
|
|
tagsURL: URL = Self.tagsURL,
|
|
validationURL: URL? = nil,
|
|
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
|
|
now: Date = Date()) async throws -> OllamaAPIUsageSnapshot
|
|
{
|
|
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
throw OllamaUsageError.missingAPIKey
|
|
}
|
|
|
|
let resolvedValidationURL = try self.resolveValidationURL(tagsURL: tagsURL, override: validationURL)
|
|
try await self.validateAPIKey(trimmed, validationURL: resolvedValidationURL, transport: transport)
|
|
|
|
var request = URLRequest(url: tagsURL)
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = Self.timeoutSeconds
|
|
request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("CodexBar/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let response: ProviderHTTPResponse
|
|
do {
|
|
response = try await transport.response(for: request)
|
|
} catch {
|
|
if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
|
|
throw CancellationError()
|
|
}
|
|
throw OllamaUsageError.networkError(error.localizedDescription)
|
|
}
|
|
|
|
switch response.statusCode {
|
|
case 200:
|
|
return try Self.parseTags(data: response.data, now: now)
|
|
case 401, 403:
|
|
throw OllamaUsageError.apiUnauthorized
|
|
default:
|
|
throw OllamaUsageError.networkError("HTTP \(response.statusCode)")
|
|
}
|
|
}
|
|
|
|
private static func validateAPIKey(
|
|
_ apiKey: String,
|
|
validationURL: URL,
|
|
transport: any ProviderHTTPTransport) async throws
|
|
{
|
|
var request = URLRequest(url: validationURL)
|
|
request.httpMethod = "POST"
|
|
request.timeoutInterval = Self.timeoutSeconds
|
|
request.httpBody = Data(#"{"query":""}"#.utf8)
|
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("CodexBar/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let response: ProviderHTTPResponse
|
|
do {
|
|
response = try await transport.response(for: request)
|
|
} catch {
|
|
if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
|
|
throw CancellationError()
|
|
}
|
|
throw OllamaUsageError.networkError(error.localizedDescription)
|
|
}
|
|
|
|
switch response.statusCode {
|
|
case 200, 400:
|
|
return
|
|
case 401, 403:
|
|
throw OllamaUsageError.apiUnauthorized
|
|
default:
|
|
throw OllamaUsageError.networkError("HTTP \(response.statusCode)")
|
|
}
|
|
}
|
|
|
|
private static func resolveValidationURL(tagsURL: URL, override: URL?) throws -> URL {
|
|
let validationURL = override
|
|
?? (tagsURL == Self.tagsURL
|
|
? Self.validationURL
|
|
: tagsURL.deletingLastPathComponent().appendingPathComponent("web_search"))
|
|
let endpointValidator = ProviderEndpointOverrideValidator()
|
|
guard endpointValidator.validatedURLAllowingLoopbackHTTP(tagsURL.absoluteString) != nil,
|
|
endpointValidator.validatedURLAllowingLoopbackHTTP(validationURL.absoluteString) != nil
|
|
else {
|
|
throw OllamaUsageError.networkError(
|
|
"Ollama API endpoints must use HTTPS or loopback HTTP.")
|
|
}
|
|
guard self.sameOrigin(tagsURL, validationURL) else {
|
|
throw OllamaUsageError.networkError(
|
|
"Ollama key validation and model catalog endpoints must share an origin.")
|
|
}
|
|
return validationURL
|
|
}
|
|
|
|
private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
|
|
lhs.scheme?.lowercased() == rhs.scheme?.lowercased()
|
|
&& lhs.host?.lowercased() == rhs.host?.lowercased()
|
|
&& self.effectivePort(lhs) == self.effectivePort(rhs)
|
|
}
|
|
|
|
private static func effectivePort(_ url: URL) -> Int? {
|
|
if let port = url.port { return port }
|
|
switch url.scheme?.lowercased() {
|
|
case "https": return 443
|
|
case "http": return 80
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
static func _parseTagsForTesting(_ data: Data, now: Date = Date()) throws -> OllamaAPIUsageSnapshot {
|
|
try self.parseTags(data: data, now: now)
|
|
}
|
|
|
|
private static func parseTags(data: Data, now: Date) throws -> OllamaAPIUsageSnapshot {
|
|
do {
|
|
let response = try JSONDecoder().decode(TagsResponse.self, from: data)
|
|
return OllamaAPIUsageSnapshot(modelCount: response.models.count, updatedAt: now)
|
|
} catch {
|
|
throw OllamaUsageError.parseFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private struct TagsResponse: Decodable {
|
|
let models: [Model]
|
|
}
|
|
|
|
private struct Model: Decodable {}
|
|
}
|