diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b369633c2..6f8a33ac34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Nous Portal: new provider showing monthly subscription credits, cycle reset, plan, and purchased credit balance by reusing the Hermes Agent OAuth login from `~/.hermes/auth.json` (#1367). + ### Fixed - Settings: disable iCloud sync sub-options when the main sync switch is off, preserving their saved choices for the next time sync is enabled (#3406). Thanks @elijahfriedman! - Antigravity: match local token-history timestamps by per-turn IDs when auxiliary or reordered steps would otherwise assign usage to the wrong day, while withholding conflicting evidence and preserving legacy timestamp recovery (#3403). Thanks @WeGoToMars! diff --git a/Sources/CodexBar/Providers/Nous/NousProviderImplementation.swift b/Sources/CodexBar/Providers/Nous/NousProviderImplementation.swift new file mode 100644 index 0000000000..5628a46a9e --- /dev/null +++ b/Sources/CodexBar/Providers/Nous/NousProviderImplementation.swift @@ -0,0 +1,42 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct NousProviderImplementation: ProviderImplementation { + let id: UsageProvider = .nous + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + NousSettingsReader.credential(environment: context.environment) != nil + } + + @MainActor + func settingsActions(context _: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + let status = NousSettingsReader.unavailableMessage(environment: ProcessInfo.processInfo.environment) + ?? "Reading the Nous Portal login Hermes Agent stored in ~/.hermes/auth.json." + return [ + ProviderSettingsActionsDescriptor( + id: "nous-hermes-login", + title: "Hermes Agent login", + subtitle: status + " CodexBar never refreshes the token; run `hermes` to renew it. " + + "Set NOUS_PORTAL_ACCESS_TOKEN or HERMES_HOME to override.", + actions: [ + ProviderSettingsActionDescriptor( + id: "nous-open-portal", + title: "Open Nous Portal", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(NousSettingsReader.defaultPortalBaseURL) + }), + ], + isVisible: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift index 49ee8bb14b..ce549591d9 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift @@ -75,5 +75,6 @@ enum ProviderImplementationManifest { { XAIProviderImplementation() }, { NotionProviderImplementation() }, { IBMBobProviderImplementation() }, + { NousProviderImplementation() }, ] } diff --git a/Sources/CodexBar/Resources/ProviderIcon-nous.svg b/Sources/CodexBar/Resources/ProviderIcon-nous.svg new file mode 100644 index 0000000000..2ca0148189 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-nous.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift new file mode 100644 index 0000000000..753965bf6a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift @@ -0,0 +1,118 @@ +import Foundation + +public enum NousProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + private static let credentials = ProviderCredentialAdapter( + supportsAPIKeyOverride: false, + requiresAPIKeyForAPISource: false, + tokenResolver: { kind, environment, _ in + guard kind == .primary, let credential = NousSettingsReader.credential(environment: environment) else { + return nil + } + let source: ProviderTokenSource = credential.source == .environment ? .environment : .authFile + return ProviderTokenResolution(token: credential.token, source: source) + }, + authDetector: { environment, _ in + NousSettingsReader.credential(environment: environment) == nil ? [] : ["api"] + }, + missingCredentialMessage: { environment in + NousSettingsReader.unavailableMessage(environment: environment) + }) + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .nous, + menuBarMetrics: ProviderMenuBarMetricCapabilities(supported: [.automatic, .primary]), + credentials: self.credentials, + metadata: ProviderMetadata( + id: .nous, + displayName: "Nous Portal", + shortDisplayName: "Nous", + sessionLabel: "Monthly credits", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Purchased credit balance from Nous Portal", + toggleTitle: "Show Nous Portal usage", + cliName: "nous", + defaultEnabled: false, + widgetSelectable: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://portal.nousresearch.com/usage", + subscriptionDashboardURL: "https://portal.nousresearch.com/manage-subscription", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .init(provider: .nous), + iconResourceName: "ProviderIcon-nous", + color: ProviderColor(red: 214 / 255, green: 165 / 255, blue: 92 / 255), + confettiPalette: [ + ProviderColor(hex: 0xD6A55C), + ProviderColor(hex: 0x1C1B1A), + ProviderColor(hex: 0xF3EADB), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Nous Portal cost summary is not available." }), + presentation: ProviderUsagePresentation( + planRow: ProviderPlanRowPresentation(label: "Plan")), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [NousAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "nous", + aliases: ["nous-portal", "hermes"], + versionDetector: nil)) + } +} + +struct NousAPIFetchStrategy: ProviderFetchStrategy { + let id = "nous.api" + let kind: ProviderFetchKind = .apiToken + private let transport: any ProviderHTTPTransport + + init(transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { + self.transport = transport + } + + /// Available whenever some Nous credential exists, even an expired one, so the fetch surfaces the specific + /// expiry or trust error instead of a bare "unavailable". + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + do { + _ = try NousSettingsReader.resolveCredential(environment: context.env) + return true + } catch NousUsageError.missingCredentials { + return false + } catch { + return true + } + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + // Credits ride along in the same account response, so attach them regardless of `includeCredits` + // (the app only sets that flag for Codex's separate credits request). + let credential = try NousSettingsReader.resolveCredential(environment: context.env) + let account = try await NousUsageFetcher.fetchAccount(credential: credential, transport: self.transport) + // The app renders `diagnostic` as a warning line, so only emit it when there is something to warn about + // (an ignored stored host) or when the caller asked for a verbose trace. + var diagnostic: String? + if credential.rejectedPortalHost != nil || context.verbose { + var note = "portal=\(credential.portalBaseURL.host ?? "?") credential=\(credential.source.label)" + if let rejected = credential.rejectedPortalHost { + note += " rejectedStoredHost=\(rejected)" + } + diagnostic = note + } + return self.makeResult( + usage: account.toUsageSnapshot(), + credits: account.toCreditsSnapshot(), + sourceLabel: "api", + diagnostic: diagnostic) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift new file mode 100644 index 0000000000..b2a53b5044 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift @@ -0,0 +1,265 @@ +import Foundation + +/// Resolves the Nous Portal access token CodexBar uses for read-only billing lookups. +/// +/// Nous Portal issues short-lived OAuth access tokens through the Hermes Agent device-code login. Refresh tokens +/// are single-use and the portal revokes the whole session when it detects reuse, so CodexBar never refreshes: +/// it only reads the access token Hermes already minted (`~/.hermes/auth.json`) or an explicit environment +/// override, and reports a clear "run `hermes`" message once that token expires. +public enum NousSettingsReader: Sendable { + public static let accessTokenEnvironmentKey = "NOUS_PORTAL_ACCESS_TOKEN" + public static let portalBaseURLEnvironmentKeys = ["NOUS_PORTAL_BASE_URL", "HERMES_PORTAL_BASE_URL"] + public static let hermesHomeEnvironmentKey = "HERMES_HOME" + public static let defaultPortalBaseURL = URL(string: "https://portal.nousresearch.com")! + /// Hosts a Hermes auth file may point the bearer token at. Anything else falls back to the default portal so a + /// tampered or stale `portal_base_url` can never redirect the credential to a third party. + public static let trustedPortalHostSuffix = "nousresearch.com" + /// Tokens closer to expiry than this are treated as expired so a fetch never races the portal clock. + public static let expirySkew: TimeInterval = 60 + + public enum CredentialSource: Sendable, Equatable { + case environment + case authFile(String) + + public var label: String { + switch self { + case .environment: "env" + case .authFile: "hermes" + } + } + } + + public struct Credential: Sendable, Equatable { + public let token: String + public let portalBaseURL: URL + public let expiresAt: Date? + public let source: CredentialSource + /// Stored `portal_base_url` that failed the trusted-host policy, kept only for diagnostics. + public let rejectedPortalHost: String? + + public init( + token: String, + portalBaseURL: URL, + expiresAt: Date?, + source: CredentialSource, + rejectedPortalHost: String? = nil) + { + self.token = token + self.portalBaseURL = portalBaseURL + self.expiresAt = expiresAt + self.source = source + self.rejectedPortalHost = rejectedPortalHost + } + + public func isExpired(now: Date = Date(), skew: TimeInterval = NousSettingsReader.expirySkew) -> Bool { + guard let expiresAt else { return false } + return expiresAt.timeIntervalSince(now) <= skew + } + } + + /// Returns a usable (non-expired) credential, or nil when none is configured. + public static func credential( + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) -> Credential? + { + try? self.resolveCredential(environment: environment, now: now) + } + + /// Resolves the credential, throwing a typed error that explains what is missing or expired. + public static func resolveCredential( + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) throws -> Credential + { + if let token = self.cleaned(environment[self.accessTokenEnvironmentKey]) { + let credential = Credential( + token: token, + portalBaseURL: self.portalBaseURL(environment: environment, stored: nil).url, + expiresAt: self.jwtExpiry(token), + source: .environment) + if credential.isExpired(now: now) { + throw NousUsageError.environmentTokenExpired + } + return credential + } + + var expired: Credential? + var sawFile = false + for url in self.authFileCandidates(environment: environment) { + guard FileManager.default.fileExists(atPath: url.path) else { continue } + sawFile = true + guard let data = try? Data(contentsOf: url), + let stored = self.parseAuthFile(data: data) + else { continue } + let resolvedPortal = self.portalBaseURL(environment: environment, stored: stored.portalBaseURL) + let credential = Credential( + token: stored.token, + portalBaseURL: resolvedPortal.url, + expiresAt: stored.expiresAt ?? self.jwtExpiry(stored.token), + source: .authFile(url.path), + rejectedPortalHost: resolvedPortal.rejectedStoredHost) + if credential.isExpired(now: now) { + expired = expired ?? credential + continue + } + return credential + } + + if let expired, case let .authFile(path) = expired.source { + throw NousUsageError.sessionExpired(path) + } + throw sawFile + ? NousUsageError.authFileInvalid(self.authFileCandidates(environment: environment).first?.path ?? "") + : NousUsageError.missingCredentials + } + + public static func unavailableMessage(environment: [String: String]) -> String? { + do { + _ = try self.resolveCredential(environment: environment) + return nil + } catch { + return (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + } + + public struct PortalResolution: Sendable, Equatable { + public let url: URL + public let origin: PortalOrigin + /// Host from a stored `portal_base_url` that was refused by the trusted-host policy. + public let rejectedStoredHost: String? + } + + public enum PortalOrigin: String, Sendable { + case environmentOverride + case storedTrusted + case `default` + } + + /// Resolves the portal origin the bearer token will be sent to. + /// + /// Precedence: explicit environment override (HTTPS, operator-controlled) → stored auth-file value when its host + /// is `nousresearch.com` or a subdomain → the default portal. A stored host outside that policy is never used. + public static func portalBaseURL(environment: [String: String], stored: String?) -> PortalResolution { + for key in self.portalBaseURLEnvironmentKeys { + if let raw = self.cleaned(environment[key]), let url = self.normalizedHTTPSURL(raw) { + return PortalResolution(url: url, origin: .environmentOverride, rejectedStoredHost: nil) + } + } + if let stored, let url = self.normalizedHTTPSURL(stored) { + if self.isTrustedPortalHost(url.host) { + return PortalResolution(url: url, origin: .storedTrusted, rejectedStoredHost: nil) + } + return PortalResolution(url: self.defaultPortalBaseURL, origin: .default, rejectedStoredHost: url.host) + } + return PortalResolution(url: self.defaultPortalBaseURL, origin: .default, rejectedStoredHost: nil) + } + + public static func isTrustedPortalHost(_ host: String?) -> Bool { + guard let host = host?.lowercased(), !host.isEmpty else { return false } + return host == self.trustedPortalHostSuffix || host.hasSuffix("." + self.trustedPortalHostSuffix) + } + + // MARK: - Hermes auth store + + struct StoredCredential: Equatable { + let token: String + let portalBaseURL: String? + let expiresAt: Date? + } + + /// Hermes stores per-profile credentials in `auth.json` and a cross-profile copy in `shared/nous_auth.json`. + /// + /// An explicit `HERMES_HOME` is the exclusive credential root: when it is set, the default `~/.hermes` root is + /// never consulted, so a missing, invalid, or expired custom profile can never fall through to another + /// profile's token. + static func authFileCandidates(environment: [String: String]) -> [URL] { + let root: URL = if let override = self.cleaned(environment[self.hermesHomeEnvironmentKey]) { + URL(fileURLWithPath: NSString(string: override).expandingTildeInPath, isDirectory: true) + } else { + self.defaultHermesHome(environment: environment) + } + return ["auth.json", "shared/nous_auth.json"].map { root.appendingPathComponent($0) } + } + + static func defaultHermesHome(environment: [String: String]) -> URL { + let home: URL = if let raw = self.cleaned(environment["HOME"]) { + URL(fileURLWithPath: NSString(string: raw).expandingTildeInPath, isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + } + return home.appendingPathComponent(".hermes", isDirectory: true) + } + + /// Accepts the three shapes Hermes writes: `providers.nous`, `credential_pool.nous[]`, or a bare state object. + static func parseAuthFile(data: Data) -> StoredCredential? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + + if let providers = root["providers"] as? [String: Any], + let nous = providers["nous"] as? [String: Any], + let stored = self.storedCredential(from: nous) + { + return stored + } + if let pool = root["credential_pool"] as? [String: Any], + let entries = pool["nous"] as? [[String: Any]] + { + for entry in entries { + if let stored = self.storedCredential(from: entry) { + return stored + } + } + } + return self.storedCredential(from: root) + } + + private static func storedCredential(from state: [String: Any]) -> StoredCredential? { + guard let token = self.cleaned(state["access_token"] as? String) else { return nil } + return StoredCredential( + token: token, + portalBaseURL: self.cleaned(state["portal_base_url"] as? String), + expiresAt: (state["expires_at"] as? String).flatMap(Self.parseISODate)) + } + + // MARK: - Helpers + + static func parseISODate(_ raw: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: raw) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: raw) + } + + /// Best-effort `exp` claim from a JWT so environment overrides also get expiry checks. + static func jwtExpiry(_ token: String) -> Date? { + let parts = token.split(separator: ".") + guard parts.count == 3 else { return nil } + var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + while payload.count % 4 != 0 { payload.append("=") } + guard let data = Data(base64Encoded: payload), + let claims = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = claims["exp"] as? Double + else { return nil } + return Date(timeIntervalSince1970: exp) + } + + /// Accepts HTTPS origins only. Plain HTTP is refused for every host, loopback included, so the bearer token + /// can never travel in cleartext regardless of where the override came from. + static func normalizedHTTPSURL(_ raw: String) -> URL? { + var value = raw + while value.hasSuffix("/") { value.removeLast() } + guard let url = URL(string: value), + url.scheme?.lowercased() == "https", + let host = url.host, !host.isEmpty + else { return nil } + return url + } + + 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()).trimmingCharacters(in: .whitespacesAndNewlines) + } + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift b/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift new file mode 100644 index 0000000000..2331c92c12 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift @@ -0,0 +1,282 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Parsed `GET /api/oauth/account` response from Nous Portal. +public struct NousAccountSummary: Sendable, Equatable { + public let email: String? + public let organizationName: String? + public let plan: String? + public let monthlyCredits: Double + public let creditsRemaining: Double + public let rolloverCredits: Double + public let currentPeriodEnd: Date? + public let purchasedCreditsRemaining: Double + public let totalUsableCredits: Double? + public let hasActiveSubscription: Bool + public let updatedAt: Date + + public init( + email: String?, + organizationName: String?, + plan: String?, + monthlyCredits: Double, + creditsRemaining: Double, + rolloverCredits: Double, + currentPeriodEnd: Date?, + purchasedCreditsRemaining: Double, + totalUsableCredits: Double?, + hasActiveSubscription: Bool, + updatedAt: Date) + { + self.email = email + self.organizationName = organizationName + self.plan = plan + self.monthlyCredits = monthlyCredits + self.creditsRemaining = creditsRemaining + self.rolloverCredits = rolloverCredits + self.currentPeriodEnd = currentPeriodEnd + self.purchasedCreditsRemaining = purchasedCreditsRemaining + self.totalUsableCredits = totalUsableCredits + self.hasActiveSubscription = hasActiveSubscription + self.updatedAt = updatedAt + } + + /// Plan row text. The header column is narrow, so keep it to the plan name; the top-up balance is shown in + /// the Credits section and the credits snapshot instead. + public var planRowText: String { + self.plan ?? (self.hasActiveSubscription ? "Subscription" : "Free") + } + + /// Monthly subscription credits consumed this cycle, as a percentage of the monthly grant. + public var monthlyUsedPercent: Double? { + guard self.monthlyCredits > 0 else { return nil } + let used = max(0, self.monthlyCredits - max(0, self.creditsRemaining)) + return min(100, used / self.monthlyCredits * 100) + } + + public func toUsageSnapshot() -> UsageSnapshot { + let primary = self.monthlyUsedPercent.map { percent in + RateWindow( + usedPercent: percent, + windowMinutes: nil, + resetsAt: self.currentPeriodEnd, + resetDescription: nil) + } + let identity = ProviderIdentitySnapshot( + providerID: .nous, + accountEmail: self.email, + accountOrganization: self.organizationName, + loginMethod: self.planRowText) + return UsageSnapshot( + primary: primary, + secondary: nil, + details: self.detailSections(), + subscriptionRenewsAt: self.currentPeriodEnd, + updatedAt: self.updatedAt, + identity: identity, + dataConfidence: .exact) + } + + public func toCreditsSnapshot() -> CreditsSnapshot { + CreditsSnapshot(remaining: self.purchasedCreditsRemaining, events: [], updatedAt: self.updatedAt) + } + + private func detailSections() -> [ProviderDetailSection] { + var sections: [ProviderDetailSection] = [] + var subscriptionRows: [ProviderDetailSection.Row] = [] + if self.monthlyCredits > 0 { + let remaining = UsageFormatter.usdString(max(0, self.creditsRemaining)) + let monthly = UsageFormatter.usdString(self.monthlyCredits) + if let row = try? ProviderDetailSection.Row(label: "Subscription credits", value: "\(remaining) of \(monthly) left") { + subscriptionRows.append(row) + } + } + if self.rolloverCredits > 0, + let row = try? ProviderDetailSection.Row( + label: "Rollover credits", + value: UsageFormatter.usdString(self.rolloverCredits)) + { + subscriptionRows.append(row) + } + if let currentPeriodEnd, + let row = try? ProviderDetailSection.Row( + label: "Renews", + value: Self.renewalFormatter.string(from: currentPeriodEnd)) + { + subscriptionRows.append(row) + } + if !subscriptionRows.isEmpty, let section = try? ProviderDetailSection(title: "Subscription", rows: subscriptionRows) { + sections.append(section) + } + + var creditRows: [ProviderDetailSection.Row] = [] + if let row = try? ProviderDetailSection.Row( + label: "Top-up credits", + value: UsageFormatter.usdString(self.purchasedCreditsRemaining)) + { + creditRows.append(row) + } + if let totalUsableCredits, + let row = try? ProviderDetailSection.Row(label: "Total usable", value: UsageFormatter.usdString(totalUsableCredits)) + { + creditRows.append(row) + } + if let section = try? ProviderDetailSection(title: "Credits", rows: creditRows) { + sections.append(section) + } + return sections + } + + private static let renewalFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} + +public enum NousUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case authFileInvalid(String) + case sessionExpired(String) + case environmentTokenExpired + case unauthorized + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Nous Portal login not found. Run `hermes` and sign in to Nous Portal, or set NOUS_PORTAL_ACCESS_TOKEN." + case let .authFileInvalid(path): + "Hermes auth file at \(path) has no Nous Portal access token. Run `hermes auth add nous` to sign in." + case let .sessionExpired(path): + "Nous Portal access token in \(path) has expired. Run `hermes` so Hermes Agent refreshes it." + case .environmentTokenExpired: + "NOUS_PORTAL_ACCESS_TOKEN has expired. Export a fresh token or unset it to use the Hermes Agent login." + case .unauthorized: + "Nous Portal rejected the access token. Run `hermes` to refresh your Hermes Agent login." + case let .networkError(message): + "Nous Portal network error: \(message)" + case let .apiError(message): + "Nous Portal API error: \(message)" + case let .parseFailed(message): + "Failed to parse Nous Portal response: \(message)" + } + } +} + +public struct NousUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.provider(.nous, scope: "usage")) + private static let timeoutSeconds: TimeInterval = 15 + public static let accountPath = "/api/oauth/account" + + public static func fetchAccount( + credential: NousSettingsReader.Credential, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> NousAccountSummary + { + let url = self.accountURL(portalBaseURL: credential.portalBaseURL) + Self.log.info("Nous Portal account request → \(url.host ?? "?") (source: \(credential.source.label))") + if let rejected = credential.rejectedPortalHost { + Self.log.warning("Ignored untrusted stored portal_base_url host \(rejected); using \(url.host ?? "?")") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(credential.token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw NousUsageError.networkError("Invalid response") + } catch let error as URLError { + throw NousUsageError.networkError(error.localizedDescription) + } + + switch response.statusCode { + case 200: + return try self.parseAccount(data: response.data, now: now) + case 401: + throw NousUsageError.unauthorized + default: + Self.log.error("Nous Portal account endpoint returned HTTP \(response.statusCode)") + throw NousUsageError.apiError(self.errorMessage(data: response.data) ?? "HTTP \(response.statusCode)") + } + } + + public static func accountURL(portalBaseURL: URL) -> URL { + URL(string: portalBaseURL.absoluteString + self.accountPath) ?? portalBaseURL + } + + static func _parseAccountForTesting(_ data: Data, now: Date = Date()) throws -> NousAccountSummary { + try self.parseAccount(data: data, now: now) + } + + private static func parseAccount(data: Data, now: Date) throws -> NousAccountSummary { + let root: [String: Any] + do { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NousUsageError.parseFailed("Expected a JSON object") + } + root = object + } catch let error as NousUsageError { + throw error + } catch { + throw NousUsageError.parseFailed(error.localizedDescription) + } + + if let message = root["error"] as? String, !message.isEmpty { + throw NousUsageError.apiError(message) + } + + let user = root["user"] as? [String: Any] ?? [:] + let organisation = root["organisation"] as? [String: Any] ?? [:] + let subscription = root["subscription"] as? [String: Any] + let access = root["paid_service_access"] as? [String: Any] ?? [:] + + guard subscription != nil || root["purchased_credits_remaining"] != nil || !access.isEmpty else { + throw NousUsageError.parseFailed("Response has no subscription or credit fields") + } + + let purchased = self.number(root["purchased_credits_remaining"]) + ?? self.number(access["purchased_credits_remaining"]) + ?? 0 + return NousAccountSummary( + email: NousSettingsReader.cleaned(user["email"] as? String), + organizationName: NousSettingsReader.cleaned(organisation["name"] as? String), + plan: NousSettingsReader.cleaned(subscription?["plan"] as? String), + monthlyCredits: self.number(subscription?["monthly_credits"]) ?? 0, + creditsRemaining: self.number(subscription?["credits_remaining"]) + ?? self.number(access["subscription_credits_remaining"]) + ?? 0, + rolloverCredits: self.number(subscription?["rollover_credits"]) ?? 0, + currentPeriodEnd: (subscription?["current_period_end"] as? String).flatMap(NousSettingsReader.parseISODate), + purchasedCreditsRemaining: purchased, + totalUsableCredits: self.number(access["total_usable_credits"]), + hasActiveSubscription: (access["has_active_subscription"] as? Bool) ?? (subscription != nil), + updatedAt: now) + } + + private static func errorMessage(data: Data) -> String? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return (root["message"] as? String) ?? (root["error"] as? String) + } + + /// Nous emits money as JSON numbers in the account payload and as decimal strings on billing routes. + static func number(_ value: Any?) -> Double? { + switch value { + case let double as Double: double + case let int as Int: Double(int) + case let string as String: Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift index 1a6682c057..5e184234bf 100644 --- a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift +++ b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift @@ -72,6 +72,7 @@ extension ProviderInstanceID { public static let xai = UsageProvider.xai.instanceID public static let notion = UsageProvider.notion.instanceID public static let ibmbob = UsageProvider.ibmbob.instanceID + public static let nous = UsageProvider.nous.instanceID } // swiftformat:enable sortDeclarations diff --git a/Sources/CodexBarCore/Providers/ProviderManifest.swift b/Sources/CodexBarCore/Providers/ProviderManifest.swift index 47631b6564..59debf684d 100644 --- a/Sources/CodexBarCore/Providers/ProviderManifest.swift +++ b/Sources/CodexBarCore/Providers/ProviderManifest.swift @@ -74,5 +74,6 @@ public enum ProviderManifest { XAIProviderDescriptor.descriptor, NotionProviderDescriptor.descriptor, IBMBobProviderDescriptor.descriptor, + NousProviderDescriptor.descriptor, ] } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index fbdbdea033..5116622a0b 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -88,6 +88,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case xai case notion case ibmbob + case nous } // swiftformat:enable sortDeclarations diff --git a/Tests/CodexBarTests/NousProviderDescriptorTests.swift b/Tests/CodexBarTests/NousProviderDescriptorTests.swift new file mode 100644 index 0000000000..e2cc9c4e69 --- /dev/null +++ b/Tests/CodexBarTests/NousProviderDescriptorTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private struct NousStubClaudeFetcher: ClaudeUsageFetching { + struct Unavailable: Error {} + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { throw Unavailable() } + func debugRawProbe(model _: String) async -> String { "stub" } + func detectVersion() -> String? { nil } +} + +struct NousProviderDescriptorTests { + @Test + func `descriptor exposes api source and hermes aliases`() { + let descriptor = NousProviderDescriptor.descriptor + #expect(descriptor.id == .nous) + #expect(descriptor.metadata.supportsCredits) + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(descriptor.cli.aliases == ["nous-portal", "hermes"]) + #expect(descriptor.metadata.dashboardURL == "https://portal.nousresearch.com/usage") + } + + @Test + func `credential adapter resolves environment token without config`() { + let credentials = NousProviderDescriptor.descriptor.credentials + let resolution = credentials?.resolveToken(environment: [ + "HOME": "/nonexistent", + "NOUS_PORTAL_ACCESS_TOKEN": "env-token", + ]) + #expect(resolution?.token == "env-token") + #expect(resolution?.source == .environment) + #expect(credentials?.resolveToken(environment: ["HOME": "/nonexistent"]) == nil) + #expect(credentials?.unavailableMessage(environment: ["HOME": "/nonexistent"])?.contains("hermes") == true) + } + + @Test + func `strategy is unavailable without a credential`() async { + let strategy = NousAPIFetchStrategy() + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .api, + includeCredits: true, + webTimeout: 5, + webDebugDumpHTML: false, + verbose: false, + env: ["HOME": "/nonexistent"], + settings: nil, + fetcher: UsageFetcher(environment: ["HOME": "/nonexistent"]), + claudeFetcher: NousStubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + #expect(await strategy.isAvailable(context) == false) + } + + @Test + func `strategy stays available with an expired environment token so the fetch reports why`() async { + let header = Data("{\"alg\":\"none\"}".utf8).base64EncodedString() + let payload = Data("{\"exp\": 946684800}".utf8).base64EncodedString().replacingOccurrences(of: "=", with: "") + let env = ["HOME": "/nonexistent", "NOUS_PORTAL_ACCESS_TOKEN": "\(header).\(payload).sig"] + let strategy = NousAPIFetchStrategy() + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .api, + includeCredits: false, + webTimeout: 5, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: NousStubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + #expect(await strategy.isAvailable(context)) + await #expect(throws: NousUsageError.environmentTokenExpired) { + _ = try await strategy.fetch(context) + } + } +} diff --git a/Tests/CodexBarTests/NousSettingsReaderTests.swift b/Tests/CodexBarTests/NousSettingsReaderTests.swift new file mode 100644 index 0000000000..7181c2c2c8 --- /dev/null +++ b/Tests/CodexBarTests/NousSettingsReaderTests.swift @@ -0,0 +1,285 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct NousSettingsReaderTests { + private static func makeHome() throws -> URL { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("nous-settings-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: home.appendingPathComponent(".hermes/shared", isDirectory: true), + withIntermediateDirectories: true) + return home + } + + private static func write(_ json: String, to url: URL) throws { + try Data(json.utf8).write(to: url) + } + + private static func authJSON(token: String, expiresAt: String, portal: String = "https://portal.nousresearch.com") -> String { + """ + { + "version": 1, + "providers": { + "nous": { + "access_token": "\(token)", + "refresh_token": "rt", + "portal_base_url": "\(portal)", + "expires_at": "\(expiresAt)" + } + } + } + """ + } + + @Test + func `environment token overrides hermes auth file`() throws { + let home = try Self.makeHome() + try Self.write( + Self.authJSON(token: "file-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + let credential = try NousSettingsReader.resolveCredential(environment: [ + "HOME": home.path, + "NOUS_PORTAL_ACCESS_TOKEN": "env-token", + "NOUS_PORTAL_BASE_URL": "https://preview.portal.example.com/", + ]) + #expect(credential.token == "env-token") + #expect(credential.source == .environment) + #expect(credential.portalBaseURL.absoluteString == "https://preview.portal.example.com") + #expect(credential.rejectedPortalHost == nil) + } + + @Test + func `reads providers section from hermes auth file`() throws { + let home = try Self.makeHome() + let path = home.appendingPathComponent(".hermes/auth.json") + try Self.write(Self.authJSON(token: "file-token", expiresAt: "2999-01-01T00:00:00+00:00"), to: path) + + let credential = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + #expect(credential.token == "file-token") + #expect(credential.source == .authFile(path.path)) + #expect(credential.portalBaseURL == NousSettingsReader.defaultPortalBaseURL) + #expect(credential.expiresAt == NousSettingsReader.parseISODate("2999-01-01T00:00:00+00:00")) + } + + @Test + func `HERMES_HOME override takes precedence over default home`() throws { + let home = try Self.makeHome() + let custom = home.appendingPathComponent("custom-hermes", isDirectory: true) + try FileManager.default.createDirectory(at: custom, withIntermediateDirectories: true) + try Self.write( + Self.authJSON(token: "default-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + try Self.write( + Self.authJSON(token: "custom-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: custom.appendingPathComponent("auth.json")) + + let credential = try NousSettingsReader.resolveCredential(environment: [ + "HOME": home.path, + "HERMES_HOME": custom.path, + ]) + #expect(credential.token == "custom-token") + } + + @Test + func `HERMES_HOME is exclusive: a missing custom profile never falls back to the default root`() throws { + let home = try Self.makeHome() + let custom = home.appendingPathComponent("custom-hermes", isDirectory: true) + try FileManager.default.createDirectory(at: custom, withIntermediateDirectories: true) + try Self.write( + Self.authJSON(token: "default-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + let env = ["HOME": home.path, "HERMES_HOME": custom.path] + + #expect(NousSettingsReader.authFileCandidates(environment: env).allSatisfy { $0.path.hasPrefix(custom.path) }) + #expect(NousSettingsReader.credential(environment: env) == nil) + #expect { + _ = try NousSettingsReader.resolveCredential(environment: env) + } throws: { error in + error as? NousUsageError == .missingCredentials + } + } + + @Test + func `HERMES_HOME is exclusive: an expired custom profile reports expiry instead of another profile`() throws { + let home = try Self.makeHome() + let custom = home.appendingPathComponent("custom-hermes", isDirectory: true) + try FileManager.default.createDirectory(at: custom, withIntermediateDirectories: true) + try Self.write( + Self.authJSON(token: "default-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + let customAuth = custom.appendingPathComponent("auth.json") + try Self.write(Self.authJSON(token: "stale", expiresAt: "2000-01-01T00:00:00+00:00"), to: customAuth) + let env = ["HOME": home.path, "HERMES_HOME": custom.path] + + #expect { + _ = try NousSettingsReader.resolveCredential(environment: env) + } throws: { error in + error as? NousUsageError == .sessionExpired(customAuth.path) + } + } + + @Test + func `HERMES_HOME is exclusive: an invalid custom profile reports the invalid file`() throws { + let home = try Self.makeHome() + let custom = home.appendingPathComponent("custom-hermes", isDirectory: true) + try FileManager.default.createDirectory(at: custom, withIntermediateDirectories: true) + try Self.write( + Self.authJSON(token: "default-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + let customAuth = custom.appendingPathComponent("auth.json") + try Self.write("{ \"version\": 1, \"providers\": {} }", to: customAuth) + let env = ["HOME": home.path, "HERMES_HOME": custom.path] + + #expect { + _ = try NousSettingsReader.resolveCredential(environment: env) + } throws: { error in + error as? NousUsageError == .authFileInvalid(customAuth.path) + } + } + + @Test + func `falls back to shared store when profile token is expired`() throws { + let home = try Self.makeHome() + try Self.write( + Self.authJSON(token: "stale", expiresAt: "2000-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + try Self.write( + """ + { "_schema": 1, "access_token": "shared-token", "expires_at": "2999-01-01T00:00:00+00:00" } + """, + to: home.appendingPathComponent(".hermes/shared/nous_auth.json")) + + let credential = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + #expect(credential.token == "shared-token") + } + + @Test + func `expired token reports session expired instead of missing`() throws { + let home = try Self.makeHome() + let path = home.appendingPathComponent(".hermes/auth.json") + try Self.write(Self.authJSON(token: "stale", expiresAt: "2000-01-01T00:00:00+00:00"), to: path) + + #expect(NousSettingsReader.credential(environment: ["HOME": home.path]) == nil) + #expect { + _ = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + } throws: { error in + error as? NousUsageError == .sessionExpired(path.path) + } + #expect(NousSettingsReader.unavailableMessage(environment: ["HOME": home.path])?.contains("expired") == true) + } + + @Test + func `missing auth file reports missing credentials`() throws { + let home = try Self.makeHome() + #expect { + _ = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + } throws: { error in + error as? NousUsageError == .missingCredentials + } + } + + @Test + func `credential pool entries are accepted when providers section is absent`() throws { + let data = Data(""" + { + "credential_pool": { + "nous": [ + { "id": "a", "auth_type": "oauth", "access_token": "pool-token", "expires_at": "2999-01-01T00:00:00+00:00" } + ] + } + } + """.utf8) + let stored = try #require(NousSettingsReader.parseAuthFile(data: data)) + #expect(stored.token == "pool-token") + #expect(stored.portalBaseURL == nil) + } + + @Test + func `jwt exp claim is used when no expiry is stored`() { + let header = Data("{\"alg\":\"none\"}".utf8).base64EncodedString() + let payload = Data("{\"exp\": 946684800}".utf8).base64EncodedString() + .replacingOccurrences(of: "=", with: "") + let token = "\(header).\(payload).sig" + #expect(NousSettingsReader.jwtExpiry(token) == Date(timeIntervalSince1970: 946_684_800)) + + let credential = NousSettingsReader.Credential( + token: token, + portalBaseURL: NousSettingsReader.defaultPortalBaseURL, + expiresAt: NousSettingsReader.jwtExpiry(token), + source: .environment) + #expect(credential.isExpired(now: Date(timeIntervalSince1970: 946_684_800 + 10))) + #expect(!credential.isExpired(now: Date(timeIntervalSince1970: 946_684_800 - 600))) + } + + @Test + func `portal base URL rejects every http override including loopback`() { + for raw in ["http://evil.example", "http://localhost:3000", "http://127.0.0.1", "ftp://portal.nousresearch.com"] { + let override = NousSettingsReader.portalBaseURL(environment: ["NOUS_PORTAL_BASE_URL": raw], stored: nil) + #expect(override.url == NousSettingsReader.defaultPortalBaseURL, "override \(raw) must be refused") + #expect(override.origin == .default) + + let stored = NousSettingsReader.portalBaseURL(environment: [:], stored: raw) + #expect(stored.url == NousSettingsReader.defaultPortalBaseURL, "stored \(raw) must be refused") + #expect(stored.rejectedStoredHost == nil, "a non-HTTPS stored value is discarded, not treated as a host") + } + #expect(NousSettingsReader.normalizedHTTPSURL("http://localhost") == nil) + #expect(NousSettingsReader.normalizedHTTPSURL("https://localhost:8443")?.absoluteString == "https://localhost:8443") + } + + @Test + func `stored portal host outside nousresearch.com is rejected and never receives the token`() throws { + let untrusted = NousSettingsReader.portalBaseURL(environment: [:], stored: "https://stored.example/") + #expect(untrusted.url == NousSettingsReader.defaultPortalBaseURL) + #expect(untrusted.origin == .default) + #expect(untrusted.rejectedStoredHost == "stored.example") + + let lookalike = NousSettingsReader.portalBaseURL(environment: [:], stored: "https://nousresearch.com.evil.example") + #expect(lookalike.url == NousSettingsReader.defaultPortalBaseURL) + #expect(lookalike.rejectedStoredHost == "nousresearch.com.evil.example") + + let home = try Self.makeHome() + let path = home.appendingPathComponent(".hermes/auth.json") + try Self.write( + Self.authJSON(token: "file-token", expiresAt: "2999-01-01T00:00:00+00:00", portal: "https://stored.example"), + to: path) + let credential = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + #expect(credential.portalBaseURL == NousSettingsReader.defaultPortalBaseURL) + #expect(credential.rejectedPortalHost == "stored.example") + #expect(NousUsageFetcher.accountURL(portalBaseURL: credential.portalBaseURL).host == "portal.nousresearch.com") + } + + @Test + func `stored nousresearch.com preview hosts are trusted`() { + let preview = NousSettingsReader.portalBaseURL(environment: [:], stored: "https://preview.portal.nousresearch.com/") + #expect(preview.url.absoluteString == "https://preview.portal.nousresearch.com") + #expect(preview.origin == .storedTrusted) + #expect(preview.rejectedStoredHost == nil) + #expect(NousSettingsReader.isTrustedPortalHost("portal.nousresearch.com")) + #expect(NousSettingsReader.isTrustedPortalHost("NousResearch.com")) + #expect(!NousSettingsReader.isTrustedPortalHost("evilnousresearch.com")) + #expect(!NousSettingsReader.isTrustedPortalHost(nil)) + } + + @Test + func `environment override remains explicit and takes precedence over a stored host`() { + let resolution = NousSettingsReader.portalBaseURL( + environment: ["NOUS_PORTAL_BASE_URL": "https://preview.portal.example.com"], + stored: "https://portal.nousresearch.com") + #expect(resolution.url.absoluteString == "https://preview.portal.example.com") + #expect(resolution.origin == .environmentOverride) + } + + @Test + func `expired environment token is rejected before any request`() { + let header = Data("{\"alg\":\"none\"}".utf8).base64EncodedString() + let payload = Data("{\"exp\": 946684800}".utf8).base64EncodedString().replacingOccurrences(of: "=", with: "") + let env = ["HOME": "/nonexistent", "NOUS_PORTAL_ACCESS_TOKEN": "\(header).\(payload).sig"] + #expect(NousSettingsReader.credential(environment: env) == nil) + #expect { + _ = try NousSettingsReader.resolveCredential(environment: env) + } throws: { error in + error as? NousUsageError == .environmentTokenExpired + } + } +} diff --git a/Tests/CodexBarTests/NousUsageFetcherTests.swift b/Tests/CodexBarTests/NousUsageFetcherTests.swift new file mode 100644 index 0000000000..460cbef0e3 --- /dev/null +++ b/Tests/CodexBarTests/NousUsageFetcherTests.swift @@ -0,0 +1,200 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Captures the outgoing request so tests can assert on the final destination and headers. +private final class NousCapturingTransport: ProviderHTTPTransport, @unchecked Sendable { + private let lock = NSLock() + private var captured: [URLRequest] = [] + let body: Data + + init(body: Data) { + self.body = body + } + + var requests: [URLRequest] { + self.lock.withLock { self.captured } + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.lock.withLock { self.captured.append(request) } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (self.body, response) + } +} + +struct NousUsageFetcherTests { + static let accountJSON = """ + { + "user": { "email": "dev@example.com", "privy_did": "did:privy:abc" }, + "organisation": { "id": "nas_organisation:1", "slug": "4314949a", "name": "dev's account" }, + "subscription": { + "plan": "Ultra", + "tier": 9, + "monthly_charge": 200, + "monthly_credits": 220, + "current_period_end": "2026-09-12T04:29:00.000Z", + "credits_remaining": 55, + "rollover_credits": 0 + }, + "purchased_credits_remaining": 19.3462440630144, + "tool_access": { "enabled": false, "coverage": { "fal": true } }, + "paid_service_access": { + "allowed": true, + "paid_access": true, + "reason": "usable_credits", + "has_active_subscription": true, + "subscription_credits_remaining": 55, + "purchased_credits_remaining": 19.3462440630144, + "total_usable_credits": 74.3462440630144 + } + } + """ + + @Test + func `parses account payload into usage and credits`() throws { + let now = Date(timeIntervalSince1970: 1_788_300_000) + let account = try NousUsageFetcher._parseAccountForTesting(Data(Self.accountJSON.utf8), now: now) + + #expect(account.email == "dev@example.com") + #expect(account.organizationName == "dev's account") + #expect(account.plan == "Ultra") + #expect(account.monthlyCredits == 220) + #expect(account.creditsRemaining == 55) + #expect(account.purchasedCreditsRemaining == 19.3462440630144) + #expect(account.totalUsableCredits == 74.3462440630144) + #expect(account.hasActiveSubscription) + #expect(account.currentPeriodEnd == NousSettingsReader.parseISODate("2026-09-12T04:29:00.000Z")) + + let usage = account.toUsageSnapshot() + let primary = try #require(usage.primary) + #expect(abs(primary.usedPercent - 75) < 0.0001) + #expect(primary.resetsAt == account.currentPeriodEnd) + #expect(usage.secondary == nil) + #expect(usage.subscriptionRenewsAt == account.currentPeriodEnd) + #expect(usage.loginMethod(for: .nous) == "Ultra") + #expect(usage.identity(for: .nous)?.accountEmail == "dev@example.com") + #expect(usage.dataConfidence == .exact) + #expect(usage.details.map(\.title) == ["Subscription", "Credits"]) + #expect(usage.details[0].rows.map(\.label) == ["Subscription credits", "Renews"]) + #expect(usage.details[0].rows[0].value == "$55.00 of $220.00 left") + + let credits = account.toCreditsSnapshot() + #expect(credits.remaining == 19.3462440630144) + #expect(credits.updatedAt == now) + } + + @Test + func `exhausted monthly grant reports fully used window`() throws { + let json = Self.accountJSON.replacingOccurrences(of: "\"credits_remaining\": 55", with: "\"credits_remaining\": 0") + let account = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) + #expect(account.toUsageSnapshot().primary?.usedPercent == 100) + } + + @Test + func `free tier without monthly credits has no rate window`() throws { + let json = """ + { + "user": { "email": "free@example.com" }, + "organisation": { "name": "free's account" }, + "subscription": { + "plan": "Free", + "monthly_credits": 0, + "credits_remaining": 0, + "rollover_credits": 0, + "current_period_end": null + }, + "purchased_credits_remaining": 2.5, + "paid_service_access": { "has_active_subscription": false, "total_usable_credits": 2.5 } + } + """ + let account = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) + let usage = account.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .nous) == "Free") + #expect(account.toCreditsSnapshot().remaining == 2.5) + #expect(usage.details.map(\.title) == ["Credits"]) + #expect(usage.details[0].rows.map(\.label) == ["Top-up credits", "Total usable"]) + } + + @Test + func `accepts decimal strings for money fields`() throws { + let json = """ + { + "subscription": { "plan": "Plus", "monthly_credits": "22", "credits_remaining": "11", "rollover_credits": "1.5" }, + "purchased_credits_remaining": "3.25" + } + """ + let account = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) + #expect(account.monthlyCredits == 22) + #expect(account.creditsRemaining == 11) + #expect(account.rolloverCredits == 1.5) + #expect(account.purchasedCreditsRemaining == 3.25) + #expect(account.toUsageSnapshot().primary?.usedPercent == 50) + } + + @Test + func `error payload surfaces as api error`() { + let json = """ + { "error": "account_missing" } + """ + #expect { + _ = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) + } throws: { error in + error as? NousUsageError == .apiError("account_missing") + } + } + + @Test + func `non-object payload is a parse failure`() { + #expect { + _ = try NousUsageFetcher._parseAccountForTesting(Data("[1, 2]".utf8)) + } throws: { error in + if case NousUsageError.parseFailed = error { return true } + return false + } + } + + @Test + func `authorization header only ever targets an https nousresearch.com origin`() async throws { + let transport = NousCapturingTransport(body: Data(Self.accountJSON.utf8)) + let token = "hermes-access-token" + + // Environment override pointing at cleartext loopback is refused; the default HTTPS portal is used. + let overridden = NousSettingsReader.portalBaseURL( + environment: ["NOUS_PORTAL_BASE_URL": "http://127.0.0.1:8080"], + stored: nil) + let envCredential = NousSettingsReader.Credential( + token: token, + portalBaseURL: overridden.url, + expiresAt: nil, + source: .environment) + _ = try await NousUsageFetcher.fetchAccount(credential: envCredential, transport: transport) + + // Stored auth-file host outside nousresearch.com is refused the same way. + let stored = NousSettingsReader.portalBaseURL(environment: [:], stored: "https://stored.example") + let fileCredential = NousSettingsReader.Credential( + token: token, + portalBaseURL: stored.url, + expiresAt: nil, + source: .authFile("/tmp/auth.json"), + rejectedPortalHost: stored.rejectedStoredHost) + _ = try await NousUsageFetcher.fetchAccount(credential: fileCredential, transport: transport) + + let requests = transport.requests + #expect(requests.count == 2) + for request in requests { + let url = try #require(request.url) + #expect(url.scheme == "https") + #expect(url.host == "portal.nousresearch.com") + #expect(url.path == NousUsageFetcher.accountPath) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(token)") + } + } + + @Test + func `account URL appends the oauth account path`() { + let url = NousUsageFetcher.accountURL(portalBaseURL: URL(string: "https://portal.example.com")!) + #expect(url.absoluteString == "https://portal.example.com/api/oauth/account") + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index b3832577d0..532e512720 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -134,6 +134,7 @@ struct ProviderArchitectureGatekeeperTests { .bedrock: "Bedrock", .jetbrains: "JetBrains", .moonshot: "Moonshot", + .nous: "Nous", ] for descriptor in ProviderDescriptorRegistry.all { let expected = overrides[descriptor.id] ?? descriptor.metadata.displayName @@ -154,8 +155,8 @@ struct ProviderArchitectureGatekeeperTests { Self.hash(descriptor.branding.burnDownWidgetColor, into: &burnDownFingerprint) } - #expect(widgetFingerprint == 16_873_014_858_015_536_126) - #expect(burnDownFingerprint == 8_686_456_525_451_224_704) + #expect(widgetFingerprint == 6_809_063_519_350_150_547) + #expect(burnDownFingerprint == 743_996_565_557_836_219) } @Test diff --git a/docs/nous.md b/docs/nous.md new file mode 100644 index 0000000000..ed89d2ead3 --- /dev/null +++ b/docs/nous.md @@ -0,0 +1,81 @@ +--- +summary: "Nous Portal provider: Hermes Agent OAuth token reuse, account endpoint parsing, and credit display." +read_when: + - Debugging Nous Portal credit or subscription parsing + - Explaining why CodexBar asks to run `hermes` to refresh the token + - Updating Nous Portal setup or environment variables +--- + +# Nous Portal Provider + +[Nous Portal](https://portal.nousresearch.com) is Nous Research's subscription and credit portal for the Hermes +inference API. Plans grant a monthly credit budget that resets each billing cycle; purchased credits top up the +balance on top of that grant. + +## Authentication + +Nous Portal only exposes its account and billing endpoints to the OAuth access token minted by the Hermes Agent +device-code login. CodexBar does not run its own login and does not store any Nous secret: + +1. Sign in once with Hermes Agent (`hermes` and choose Nous Portal, or `hermes auth add nous`). +2. Hermes writes the token to `~/.hermes/auth.json` (and a cross-profile copy to `~/.hermes/shared/nous_auth.json`). +3. CodexBar reads the access token from those files on every refresh. + +Overrides: + +- `HERMES_HOME`: directory holding `auth.json` when Hermes runs from a custom root or profile. It is exclusive: when + set, `~/.hermes` is never consulted, so a missing or expired custom profile reports an error rather than silently + using another profile's login. +- `NOUS_PORTAL_ACCESS_TOKEN`: use this token instead of the Hermes files. +- `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL`: point at a preview portal deployment. HTTPS only; plain HTTP + is refused for every host, loopback included, and the default portal is used instead. + +### Where the token is sent + +The bearer token only ever goes to one origin, resolved in this order: + +1. An explicit `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL` override (HTTPS only, set by you). +2. The `portal_base_url` stored by Hermes, but only when its host is `nousresearch.com` or a subdomain. +3. `https://portal.nousresearch.com`. + +A stored host outside `nousresearch.com` is ignored, logged as a warning, and reported in the verbose trace as +`rejectedStoredHost=`; the request then goes to the default portal. Expired tokens, whether from the auth file or +from `NOUS_PORTAL_ACCESS_TOKEN`, are rejected before any request is made. + +### Why CodexBar never refreshes the token + +Nous access tokens live for about an hour. The refresh token is single-use: the portal rotates it on every refresh and +revokes the entire session when it sees an old one replayed. A second client refreshing behind Hermes's back would +therefore log Hermes out. CodexBar only reads the current access token and, once it has expired, shows +"run `hermes` so Hermes Agent refreshes it". Any Hermes command (or a running Hermes gateway) renews the token. + +## Data Source + +One request per refresh: `GET {portal}/api/oauth/account` with the bearer token. + +| Field | Display | +| --- | --- | +| `subscription.monthly_credits`, `subscription.credits_remaining` | Primary meter "Monthly credits" as percent used | +| `subscription.current_period_end` | Meter reset time and renewal date | +| `subscription.plan` | Plan row (plan name only, e.g. `Ultra`) | +| `subscription.rollover_credits` | Subscription detail row when non-zero | +| `purchased_credits_remaining` | Credits snapshot and the "Top-up credits" row in the Credits section | +| `paid_service_access.total_usable_credits` | Credits detail row | +| `user.email`, `organisation.name` | Identity (siloed to this provider) | + +Money fields are accepted both as JSON numbers and as decimal strings. A Free tier with no monthly grant shows no +meter and only the purchased balance. + +## API keys + +Nous Portal API keys authenticate only the inference API (`/v1/chat/completions`, `/v1/completions`). The portal's +account and billing endpoints accept the OAuth access token only, so CodexBar cannot show credits from an API key. +Use the Hermes Agent login. + +## CLI + +```bash +codexbar usage --provider nous +``` + +Aliases: `nous-portal`, `hermes`. Source modes: `auto`, `api`. diff --git a/docs/provider-ids.md b/docs/provider-ids.md index 214a88e646..0e59e507a4 100644 --- a/docs/provider-ids.md +++ b/docs/provider-ids.md @@ -2,4 +2,4 @@ # Provider IDs -`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`, `nous`. diff --git a/docs/providers.md b/docs/providers.md index 3956c6fb4d..74f1285cca 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 69 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 70 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -98,6 +98,7 @@ complete when the available scan window covers fewer days. | Fireworks | API key + account slug → 30-day spend from the billing summary API (`api`). | | DeepInfra | API key from env or token accounts → billing checklist + monthly usage endpoints (`api`). | | Moonshot | API key from config/env → balance endpoint (`api`). | +| Nous Portal | Hermes Agent OAuth access token from `~/.hermes/auth.json` (or env) → account/billing API (`api`). | | Codebuff | API token from config/env or `codebuff login` credentials → usage API (`api`). | | Crof | API key from config/env → credit balance + optional request quota API (`api`). | | Venice | API key from config/env → DIEM/USD balance API (`api`). | @@ -483,6 +484,18 @@ provider-specific cookie validation, endpoints, login detection, and error trans - Status: none yet. - Details: `docs/moonshot.md`. + +## Nous Portal +- Reuses the OAuth access token Hermes Agent stores in `~/.hermes/auth.json` (falls back to `~/.hermes/shared/nous_auth.json`; + `HERMES_HOME` replaces the directory and is exclusive). `NOUS_PORTAL_ACCESS_TOKEN` supplies a token directly. +- CodexBar never calls the refresh endpoint: Nous refresh tokens are single-use and reuse revokes the Hermes session. + When the stored token expires, the card asks you to run `hermes` so Hermes refreshes it. +- Reads `GET /api/oauth/account`: monthly subscription credits used/remaining with the cycle reset date, plan name, + and the purchased credit balance (shown as credits). +- The token is only sent to `nousresearch.com` hosts (or an explicit `NOUS_PORTAL_BASE_URL` override you set); an + untrusted stored `portal_base_url` is ignored. API keys are inference-only and cannot read credits. +- Details: `docs/nous.md`. + ## Venice - API key via `VENICE_API_KEY` / `VENICE_KEY` env var or Venice token accounts. - Shows current DIEM or USD balance; DIEM epoch allocation progress when available.