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.