diff --git a/README.md b/README.md index 5e86f43181..9a85655700 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,13 @@ printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider eleve `set-api-key` trims the piped value, stores it with restrictive config-file permissions, and enables the provider by default. Use `--no-enable` to only save the key, or `--api-key ` for one-off local scripts where shell history is not a concern. See [CLI configuration](docs/cli-configuration.md) for the full flow. +### Hermes Agent local usage + +`codexbar hermes-usage` reads local Hermes `state.db` attribution in SQLite read-only mode and reports mapped +provider/model/task tokens. Billed cost, subscription-included usage, Hermes estimates, and API-equivalent estimates +remain separate; the source is not automatically merged with provider-native billing totals. See +[Hermes Agent local usage](docs/hermes-usage.md). + ## Providers - [Codex](docs/codex.md) — OAuth API or local Codex CLI, plus optional OpenAI web dashboard extras. diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 80e65ecfd5..1fd92883a2 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -53,6 +53,8 @@ enum CodexBarCLI { await self.runUsageDisplay(path: invocation.path, values: invocation.parsedValues) case ["cost"]: await self.runCost(invocation.parsedValues) + case ["hermes-usage"]: + await self.runHermesUsage(invocation.parsedValues) case ["sessions", "list"]: await self.runSessions(invocation.parsedValues) case ["sessions", "focus"]: @@ -163,6 +165,7 @@ enum CodexBarCLI { let cardsSignature = CommandSignature.describe(CardsOptions()) let usageSignature = CommandSignature.describe(UsageOptions()) let costSignature = CommandSignature.describe(CostOptions()) + let hermesUsageSignature = CommandSignature.describe(HermesUsageOptions()) let sessionsSignature = CommandSignature.describe(SessionsOptions()) let sessionsFocusSignature = CommandSignature.describe(SessionsFocusOptions()) let serveSignature = CommandSignature.describe(ServeOptions()) @@ -195,6 +198,11 @@ enum CodexBarCLI { abstract: "Print local cost usage as text or JSON", discussion: nil, signature: costSignature), + CommandDescriptor( + name: "hermes-usage", + abstract: "Read local Hermes token and cost attribution", + discussion: nil, + signature: hermesUsageSignature), CommandDescriptor( name: "sessions", abstract: "List live Codex, Claude Code, pi, and OMP sessions", diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 3399ea8374..193e25a321 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -130,6 +130,30 @@ extension CodexBarCLI { """ } + static func hermesUsageHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar hermes-usage [--database ] + [--provider ] + [--format text|json] [--json] [--json-only] [--pretty] + [--refresh-pricing] [--no-color] + + Description: + Read Hermes Agent token attribution from local state.db files in SQLite read-only mode. + Without --database, discovers ~/.hermes/state.db and ~/.hermes/profiles/*/state.db. + Reports actual billed cost, Hermes estimates, subscription-included usage, and + API-equivalent estimates separately. Cumulative rows are not presented as exact daily history. + Routes such as auto/custom remain unmapped unless Hermes persisted an exact billing provider. + + Examples: + codexbar hermes-usage + codexbar hermes-usage --provider codex --json --pretty + codexbar hermes-usage --database ~/.hermes/profiles/work/state.db --refresh-pricing + """ + } + static func sessionsHelp(version: String) -> String { """ CodexBar \(version) @@ -459,6 +483,8 @@ extension CodexBarCLI { [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] [--provider-native-only] [--days ] [--group-by project] + codexbar hermes-usage [--database ] [--provider ] + [--format text|json] [--json] [--pretty] [--refresh-pricing] codexbar sessions [--json|--json-v2] [--pretty] codexbar sessions focus codexbar dashboard [--pretty] [--timeout ] [--output ] diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 80a95ce3b8..7fca27664f 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -396,6 +396,10 @@ extension CodexBarCLI { CommandSignature.describe(CostOptions()) } + static func _hermesUsageSignatureForTesting() -> CommandSignature { + CommandSignature.describe(HermesUsageOptions()) + } + static func _cacheSignatureForTesting() -> CommandSignature { CommandSignature.describe(CacheOptions()) } diff --git a/Sources/CodexBarCLI/CLIHermesUsageCommand.swift b/Sources/CodexBarCLI/CLIHermesUsageCommand.swift new file mode 100644 index 0000000000..281ea42e32 --- /dev/null +++ b/Sources/CodexBarCLI/CLIHermesUsageCommand.swift @@ -0,0 +1,237 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + static func runHermesUsage(_ values: ParsedValues) async { + let output = CLIOutputPreferences.from(values: values) + let useColor = Self.shouldUseColor( + noColor: values.flags.contains("noColor"), + format: output.format) + + do { + let provider = try Self.decodeHermesUsageProvider(from: values) + let explicitDatabases = Self.decodeHermesUsageDatabaseURLs(from: values) + let sources: [HermesUsageDatabaseSource] = if explicitDatabases.isEmpty { + HermesUsageDatabaseDiscovery.discover() + } else { + explicitDatabases.map { + HermesUsageDatabaseSource( + label: HermesUsageDatabaseDiscovery.label(forDatabaseURL: $0), + databaseURL: $0) + } + } + guard !sources.isEmpty else { + throw CLIArgumentError( + "No Hermes state.db databases found. Pass --database or set HERMES_HOME.") + } + + let scanner = HermesUsageScanner() + if values.flags.contains("refreshPricing") { + await scanner.refreshPricingIfNeeded() + } + var report = try scanner.scan(sources: sources) + try Self.validateHermesUsageSources(report.sources) + if let provider { + report = try Self.filterHermesUsageReport(report, provider: provider) + } + + switch output.format { + case .text: + print(Self.renderHermesUsageText(report, useColor: useColor)) + case .json: + Self.printJSON(report, pretty: output.pretty) + } + Self.exit(code: .success, output: output, kind: .runtime) + } catch { + Self.exit( + code: Self.mapError(error), + message: "Error: \(error.localizedDescription)", + output: output, + kind: error is CLIArgumentError ? .args : .runtime) + } + } + + static func decodeHermesUsageProvider(from values: ParsedValues) throws -> UsageProvider? { + guard let raw = values.options["provider"]?.last?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + raw.lowercased() != "all" + else { return nil } + guard let provider = UsageProvider(rawValue: raw.lowercased()) else { + throw CLIArgumentError("Unknown CodexBar provider '\(raw)'.") + } + let supported = Set(HermesUsageProviderMapping.supportedBillingProviders.compactMap { + HermesUsageProviderMapping.route(for: $0)?.provider + }) + guard supported.contains(provider) else { + let list = supported.map(\.rawValue).sorted().joined(separator: ", ") + throw CLIArgumentError("Hermes local usage is not mapped to \(raw). Supported providers: \(list).") + } + return provider + } + + static func decodeHermesUsageDatabaseURLs( + from values: ParsedValues, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> [URL] + { + let paths = values.options["database"]?.flatMap { raw in + raw.split(separator: ",", omittingEmptySubsequences: true).map(String.init) + } ?? [] + var seen: Set = [] + return paths.compactMap { raw -> URL? in + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let path: String = if trimmed == "~" { + homeDirectory.path + } else if trimmed.hasPrefix("~/") { + homeDirectory.appendingPathComponent(String(trimmed.dropFirst(2))).path + } else { + NSString(string: trimmed).expandingTildeInPath + } + let url = URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL + return seen.insert(url.path).inserted ? url : nil + } + } + + static func filterHermesUsageReport( + _ report: HermesUsageReport, + provider: UsageProvider) throws -> HermesUsageReport + { + guard let providerReport = report.providers.first(where: { $0.provider == provider }) else { + throw CLIArgumentError("No Hermes usage found for CodexBar provider '\(provider.rawValue)'.") + } + return HermesUsageReport( + schemaVersion: report.schemaVersion, + generatedAt: report.generatedAt, + sources: report.sources, + summary: providerReport.summary, + providers: [providerReport], + unmapped: [], + warnings: report.warnings) + } + + static func validateHermesUsageSources(_ sources: [HermesUsageSourceReport]) throws { + guard sources.contains(where: { $0.status == .read }) else { + let details = sources.map { "\($0.label): \($0.status.rawValue)" }.joined(separator: ", ") + throw HermesUsageCLIError.noReadableSources(details: details) + } + } + + static func renderHermesUsageText(_ report: HermesUsageReport, useColor _: Bool) -> String { + let number = NumberFormatter() + number.numberStyle = .decimal + number.locale = Locale(identifier: "en_US_POSIX") + + func integer(_ value: Int) -> String { + number.string(from: NSNumber(value: value)) ?? String(value) + } + + func currency(_ value: Double?) -> String { + value.map { UsageFormatter.currencyString($0, currencyCode: "USD") } ?? "unknown" + } + + func summaryLines(_ summary: HermesUsageSummary, prefix: String = "") -> [String] { + let tokens = summary.tokens + var lines = [ + "\(prefix)Tokens: \(integer(tokens.total)) " + + "(input \(integer(tokens.input)), cache read \(integer(tokens.cacheRead)), " + + "cache write \(integer(tokens.cacheWrite)), output \(integer(tokens.output)), " + + "reasoning \(integer(tokens.reasoning)) subset of output)", + "\(prefix)Requests: \(integer(summary.requests)) · Sessions: \(integer(summary.sessions))", + "\(prefix)Actual billed: \(currency(summary.actualCostUSD))", + "\(prefix)Hermes estimate: \(currency(summary.hermesEstimatedCostUSD))", + "\(prefix)API-equivalent: " + (summary.apiEquivalentCostUSD.map { "~\(currency($0))" } ?? "unknown") + + " · priced \(integer(summary.apiEquivalentPricedTokens)) / " + + "unpriced \(integer(summary.apiEquivalentUnpricedTokens)) tokens", + "\(prefix)Included in subscription: \(integer(summary.subscriptionIncludedTokens)) tokens · " + + "\(integer(summary.subscriptionIncludedRequests)) requests", + ] + if summary.actualCostUSD == nil { + lines[2] += " (not reported)" + } + if summary.hermesEstimatedCostUSD == nil { + lines[3] += " (not reported)" + } + return lines + } + + var lines = ["Hermes local usage snapshot"] + lines.append(contentsOf: summaryLines(report.summary)) + if !report.providers.isEmpty { + lines.append("") + lines.append("Mapped providers:") + for provider in report.providers { + let descriptor = ProviderDescriptorRegistry.descriptor(for: provider.provider) + lines.append( + "- \(descriptor.metadata.displayName): \(integer(provider.summary.tokens.total)) tokens · " + + "actual \(currency(provider.summary.actualCostUSD)) · " + + "Hermes estimate \(currency(provider.summary.hermesEstimatedCostUSD)) · " + + "API-equivalent " + + (provider.summary.apiEquivalentCostUSD.map { "~\(currency($0))" } ?? "unknown")) + } + } + if !report.unmapped.isEmpty { + lines.append("") + lines.append("Unmapped routes: \(report.unmapped.map(\.billingProvider).joined(separator: ", "))") + } + if !report.sources.isEmpty { + lines.append("") + lines.append("Sources:") + for source in report.sources { + lines.append("- \(source.label): \(source.status.rawValue) (\(source.databasePath))") + } + } + if !report.warnings.isEmpty { + lines.append("") + lines.append("Notes:") + lines.append(contentsOf: report.warnings.map { "- \($0)" }) + } + return lines.joined(separator: "\n") + } +} + +enum HermesUsageCLIError: LocalizedError, Equatable { + case noReadableSources(details: String) + + var errorDescription: String? { + switch self { + case let .noReadableSources(details): + "No Hermes usage databases could be read (\(details))." + } + } +} + +struct HermesUsageOptions: CommanderParsable { + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Option(name: .long("database"), help: "Hermes state.db path(s), comma-separated; overrides discovery") + var database: [String]? + + @Option(name: .long("provider"), help: "Filter by mapped CodexBar provider, or all") + var provider: String? + + @Option(name: .long("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("json"), help: "") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false + + @Flag(name: .long("no-color"), help: "Disable ANSI colors in text output") + var noColor: Bool = false + + @Flag(name: .long("refresh-pricing"), help: "Refresh the public models.dev catalog before estimating") + var refreshPricing: Bool = false +} diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index 81eaf49b15..97d194a7a9 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -31,6 +31,8 @@ extension CodexBarCLI { print(Self.usageHelp(version: version)) case "cost": print(Self.costHelp(version: version)) + case "hermes-usage": + print(Self.hermesUsageHelp(version: version)) case "sessions", "focus": print(Self.sessionsHelp(version: version)) case "dashboard": diff --git a/Sources/CodexBarCore/HermesUsageScanner.swift b/Sources/CodexBarCore/HermesUsageScanner.swift new file mode 100644 index 0000000000..d00554614b --- /dev/null +++ b/Sources/CodexBarCore/HermesUsageScanner.swift @@ -0,0 +1,955 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +#if canImport(SQLite3) || canImport(CSQLite3) +private let hermesSQLiteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) +#endif + +public struct HermesUsageProviderRoute: Sendable, Equatable, Codable { + public let provider: UsageProvider + public let modelsDevProviderID: String + + public init(provider: UsageProvider, modelsDevProviderID: String) { + self.provider = provider + self.modelsDevProviderID = modelsDevProviderID + } +} + +/// Conservative mapping from Hermes' persisted `billing_provider` routes to CodexBar providers. +/// +/// Only exact, product-equivalent routes belong here. Routing placeholders (`auto`, `custom`, `moa`) +/// and broader clouds such as Azure AI Foundry deliberately stay unmapped because their model vendor +/// cannot be recovered reliably from the route name alone. +public enum HermesUsageProviderMapping { + /// Provider-specific by design: Hermes persists external billing_provider route IDs, so the import boundary + /// needs one explicit audited mapping to CodexBar identities and models.dev rate sources. + private static let routes: [String: HermesUsageProviderRoute] = [ + "alibaba": .init(provider: .qwencloud, modelsDevProviderID: "alibaba"), + // Subscription catalogs publish zero plan rates. API-equivalent estimates use the + // corresponding direct vendor catalog instead of turning included usage into a false $0. + "alibaba-coding-plan": .init(provider: .alibaba, modelsDevProviderID: "alibaba"), + "anthropic": .init(provider: .claude, modelsDevProviderID: "anthropic"), + "bedrock": .init(provider: .bedrock, modelsDevProviderID: "amazon-bedrock"), + "copilot": .init(provider: .copilot, modelsDevProviderID: "github-copilot"), + "copilot-acp": .init(provider: .copilot, modelsDevProviderID: "github-copilot"), + "deepinfra": .init(provider: .deepinfra, modelsDevProviderID: "deepinfra"), + "deepseek": .init(provider: .deepseek, modelsDevProviderID: "deepseek"), + "fireworks": .init(provider: .fireworks, modelsDevProviderID: "fireworks-ai"), + "gemini": .init(provider: .gemini, modelsDevProviderID: "google"), + "kilocode": .init(provider: .kilo, modelsDevProviderID: "kilo"), + "kimi-coding": .init(provider: .kimi, modelsDevProviderID: "moonshotai"), + "kimi-coding-cn": .init(provider: .moonshot, modelsDevProviderID: "moonshotai-cn"), + "minimax": .init(provider: .minimax, modelsDevProviderID: "minimax"), + "minimax-cn": .init(provider: .minimax, modelsDevProviderID: "minimax-cn"), + "minimax-oauth": .init(provider: .minimax, modelsDevProviderID: "minimax"), + "ollama-cloud": .init(provider: .ollama, modelsDevProviderID: "ollama-cloud"), + "openai-api": .init(provider: .openai, modelsDevProviderID: "openai"), + "openai-codex": .init(provider: .codex, modelsDevProviderID: "openai"), + "opencode-go": .init(provider: .opencodego, modelsDevProviderID: "opencode-go"), + "opencode-zen": .init(provider: .opencode, modelsDevProviderID: "opencode"), + "openrouter": .init(provider: .openrouter, modelsDevProviderID: "openrouter"), + "qwen-oauth": .init(provider: .qwencloud, modelsDevProviderID: "alibaba"), + "stepfun": .init(provider: .stepfun, modelsDevProviderID: "stepfun-ai"), + "vertex": .init(provider: .vertexai, modelsDevProviderID: "google-vertex"), + "xai": .init(provider: .xai, modelsDevProviderID: "xai"), + "xai-oauth": .init(provider: .grok, modelsDevProviderID: "xai"), + "xiaomi": .init(provider: .mimo, modelsDevProviderID: "xiaomi"), + "zai": .init(provider: .zai, modelsDevProviderID: "zai"), + ] + + public static var supportedBillingProviders: [String] { + self.routes.keys.sorted() + } + + public static func route( + for rawBillingProvider: String, + billingBaseURL: String? = nil) -> HermesUsageProviderRoute? + { + let normalized = rawBillingProvider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty, let route = self.routes[normalized] else { return nil } + let host = billingBaseURL.flatMap(URL.init(string:))?.host?.lowercased() + + // Provider-specific by design: persisted Kimi/Moonshot, MiniMax OAuth, and StepFun route IDs span + // region/product hosts. + switch normalized { + case "kimi-coding", "kimi-coding-cn": + if host == "api.moonshot.ai" { + return .init(provider: .moonshot, modelsDevProviderID: "moonshotai") + } + if host == "api.moonshot.cn" { + return .init(provider: .moonshot, modelsDevProviderID: "moonshotai-cn") + } + if host == "api.kimi.com" { + return .init(provider: .kimi, modelsDevProviderID: "moonshotai") + } + case "minimax-oauth": + if host == "api.minimaxi.com" { + return .init(provider: .minimax, modelsDevProviderID: "minimax-cn") + } + if host == "api.minimax.io" { + return .init(provider: .minimax, modelsDevProviderID: "minimax") + } + case "stepfun": + if host == "api.stepfun.com" { + return .init(provider: .stepfun, modelsDevProviderID: "stepfun") + } + if host == "api.stepfun.ai" { + return .init(provider: .stepfun, modelsDevProviderID: "stepfun-ai") + } + default: + break + } + return route + } +} + +public struct HermesUsageTokenCounts: Sendable, Equatable, Codable { + public var input: Int + public var output: Int + public var cacheRead: Int + public var cacheWrite: Int + public var reasoning: Int + + public init(input: Int = 0, output: Int = 0, cacheRead: Int = 0, cacheWrite: Int = 0, reasoning: Int = 0) { + self.input = max(0, input) + self.output = max(0, output) + self.cacheRead = max(0, cacheRead) + self.cacheWrite = max(0, cacheWrite) + self.reasoning = max(0, reasoning) + } + + /// Hermes' canonical token buckets are disjoint except reasoning, which is a subset of output. + /// Reasoning is therefore reported separately and intentionally excluded from this total. + public var total: Int { + self.input + self.output + self.cacheRead + self.cacheWrite + } + + fileprivate static func + (lhs: Self, rhs: Self) -> Self { + Self( + input: lhs.input + rhs.input, + output: lhs.output + rhs.output, + cacheRead: lhs.cacheRead + rhs.cacheRead, + cacheWrite: lhs.cacheWrite + rhs.cacheWrite, + reasoning: lhs.reasoning + rhs.reasoning) + } + + fileprivate static func += (lhs: inout Self, rhs: Self) { + lhs = lhs + rhs + } + + fileprivate static func positiveResidual(_ aggregate: Self, minus attributed: Self) -> Self { + Self( + input: max(0, aggregate.input - attributed.input), + output: max(0, aggregate.output - attributed.output), + cacheRead: max(0, aggregate.cacheRead - attributed.cacheRead), + cacheWrite: max(0, aggregate.cacheWrite - attributed.cacheWrite), + reasoning: max(0, aggregate.reasoning - attributed.reasoning)) + } +} + +public struct HermesUsageSummary: Sendable, Equatable, Codable { + public let tokens: HermesUsageTokenCounts + public let requests: Int + public let sessions: Int + public let hermesEstimatedCostUSD: Double? + public let actualCostUSD: Double? + public let subscriptionIncludedTokens: Int + public let subscriptionIncludedRequests: Int + public let apiEquivalentCostUSD: Double? + public let apiEquivalentPricedTokens: Int + public let apiEquivalentUnpricedTokens: Int + + public init( + tokens: HermesUsageTokenCounts, + requests: Int, + sessions: Int, + hermesEstimatedCostUSD: Double?, + actualCostUSD: Double?, + subscriptionIncludedTokens: Int, + subscriptionIncludedRequests: Int, + apiEquivalentCostUSD: Double?, + apiEquivalentPricedTokens: Int, + apiEquivalentUnpricedTokens: Int) + { + self.tokens = tokens + self.requests = max(0, requests) + self.sessions = max(0, sessions) + self.hermesEstimatedCostUSD = hermesEstimatedCostUSD + self.actualCostUSD = actualCostUSD + self.subscriptionIncludedTokens = max(0, subscriptionIncludedTokens) + self.subscriptionIncludedRequests = max(0, subscriptionIncludedRequests) + self.apiEquivalentCostUSD = apiEquivalentCostUSD + self.apiEquivalentPricedTokens = max(0, apiEquivalentPricedTokens) + self.apiEquivalentUnpricedTokens = max(0, apiEquivalentUnpricedTokens) + } +} + +public struct HermesUsageNamedBreakdown: Sendable, Equatable, Codable { + public let name: String + public let summary: HermesUsageSummary + + public init(name: String, summary: HermesUsageSummary) { + self.name = name + self.summary = summary + } +} + +public struct HermesUsageProviderReport: Sendable, Equatable, Codable { + public let provider: UsageProvider + public let billingProviders: [String] + public let summary: HermesUsageSummary + public let models: [HermesUsageNamedBreakdown] + public let tasks: [HermesUsageNamedBreakdown] + + public init( + provider: UsageProvider, + billingProviders: [String], + summary: HermesUsageSummary, + models: [HermesUsageNamedBreakdown], + tasks: [HermesUsageNamedBreakdown]) + { + self.provider = provider + self.billingProviders = billingProviders + self.summary = summary + self.models = models + self.tasks = tasks + } +} + +public struct HermesUsageUnmappedReport: Sendable, Equatable, Codable { + public let billingProvider: String + public let summary: HermesUsageSummary + public let models: [HermesUsageNamedBreakdown] + public let tasks: [HermesUsageNamedBreakdown] + + public init( + billingProvider: String, + summary: HermesUsageSummary, + models: [HermesUsageNamedBreakdown], + tasks: [HermesUsageNamedBreakdown]) + { + self.billingProvider = billingProvider + self.summary = summary + self.models = models + self.tasks = tasks + } +} + +public enum HermesUsageSourceStatus: String, Sendable, Equatable, Codable { + case read + case missing + case incompatible + case locked + case corrupt + case unreadable +} + +public struct HermesUsageSourceReport: Sendable, Equatable, Codable { + public let label: String + public let databasePath: String + public let status: HermesUsageSourceStatus + public let error: String? + + public init(label: String, databasePath: String, status: HermesUsageSourceStatus, error: String? = nil) { + self.label = label + self.databasePath = databasePath + self.status = status + self.error = error + } +} + +public struct HermesUsageReport: Sendable, Equatable, Codable { + public let schemaVersion: Int + public let generatedAt: Date + public let sources: [HermesUsageSourceReport] + public let summary: HermesUsageSummary + public let providers: [HermesUsageProviderReport] + public let unmapped: [HermesUsageUnmappedReport] + public let warnings: [String] + + public init( + schemaVersion: Int = 1, + generatedAt: Date, + sources: [HermesUsageSourceReport], + summary: HermesUsageSummary, + providers: [HermesUsageProviderReport], + unmapped: [HermesUsageUnmappedReport], + warnings: [String]) + { + self.schemaVersion = schemaVersion + self.generatedAt = generatedAt + self.sources = sources + self.summary = summary + self.providers = providers + self.unmapped = unmapped + self.warnings = warnings + } +} + +public struct HermesUsageDatabaseSource: Sendable, Equatable, Codable { + public let label: String + public let databaseURL: URL + + public init(label: String, databaseURL: URL) { + self.label = label + self.databaseURL = databaseURL + } +} + +public enum HermesUsageDatabaseDiscovery { + public static func discover( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> [HermesUsageDatabaseSource] + { + let root = homeDirectory.appendingPathComponent(".hermes", isDirectory: true) + var candidates: [HermesUsageDatabaseSource] = [] + let defaultDatabase = root.appendingPathComponent("state.db", isDirectory: false) + if FileManager.default.fileExists(atPath: defaultDatabase.path) { + candidates.append(.init(label: "default", databaseURL: defaultDatabase)) + } + + let profilesRoot = root.appendingPathComponent("profiles", isDirectory: true) + let profileDirectories = (try? FileManager.default.contentsOfDirectory( + at: profilesRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles])) ?? [] + for profileDirectory in profileDirectories.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let values = try? profileDirectory.resourceValues(forKeys: [.isDirectoryKey]) + guard values?.isDirectory == true else { continue } + let database = profileDirectory.appendingPathComponent("state.db", isDirectory: false) + guard FileManager.default.fileExists(atPath: database.path) else { continue } + candidates.append(.init(label: profileDirectory.lastPathComponent, databaseURL: database)) + } + + if let rawActiveHome = environment["HERMES_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawActiveHome.isEmpty + { + let expanded = NSString(string: rawActiveHome).expandingTildeInPath + let activeHome = URL(fileURLWithPath: expanded, isDirectory: true) + let database = activeHome.appendingPathComponent("state.db", isDirectory: false) + if FileManager.default.fileExists(atPath: database.path) { + candidates.append(.init( + label: Self.label(forDatabaseURL: database), + databaseURL: database)) + } + } + + var seen: Set = [] + return candidates.filter { source in + let path = source.databaseURL.resolvingSymlinksInPath().standardizedFileURL.path + return seen.insert(path).inserted + } + } + + public static func label(forDatabaseURL databaseURL: URL) -> String { + let parent = databaseURL.deletingLastPathComponent() + if parent.lastPathComponent == ".hermes" { + return "default" + } + if parent.deletingLastPathComponent().lastPathComponent == "profiles" { + return parent.lastPathComponent + } + let label = parent.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + return label.isEmpty ? "Hermes" : label + } +} + +public struct HermesUsageScanner: Sendable { + private let modelsDevCacheRoot: URL? + + public init(modelsDevCacheRoot: URL? = nil) { + self.modelsDevCacheRoot = modelsDevCacheRoot + } + + /// Refreshes the public models.dev catalog used for the independent API-equivalent estimate. + /// The Hermes database remains local and read-only; only the pricing catalog uses the network. + public func refreshPricingIfNeeded(now: Date = Date()) async { + await ModelsDevPricingPipeline.refreshIfNeeded(now: now, cacheRoot: self.modelsDevCacheRoot) + } + + public func scan( + databaseURLs: [URL], + generatedAt: Date = Date()) throws -> HermesUsageReport + { + let sources = databaseURLs.map { + HermesUsageDatabaseSource( + label: HermesUsageDatabaseDiscovery.label(forDatabaseURL: $0), + databaseURL: $0) + } + return try self.scan(sources: sources, generatedAt: generatedAt) + } + + public func scan( + sources: [HermesUsageDatabaseSource], + generatedAt: Date = Date()) throws -> HermesUsageReport + { + var rows: [HermesUsageRow] = [] + var sourceReports: [HermesUsageSourceReport] = [] + var seen: Set = [] + + for source in sources { + let standardizedURL = source.databaseURL.resolvingSymlinksInPath().standardizedFileURL + guard seen.insert(standardizedURL.path).inserted else { continue } + guard FileManager.default.fileExists(atPath: standardizedURL.path) else { + sourceReports.append(.init( + label: source.label, + databasePath: standardizedURL.path, + status: .missing)) + continue + } + + do { + let sourceRows = try Self.readRows(databaseURL: standardizedURL, sourceLabel: source.label) + rows.append(contentsOf: sourceRows) + sourceReports.append(.init( + label: source.label, + databasePath: standardizedURL.path, + status: .read)) + } catch let error as HermesUsageReadError { + sourceReports.append(.init( + label: source.label, + databasePath: standardizedURL.path, + status: error.status, + error: error.message)) + } + } + + return self.makeReport(rows: rows, sources: sourceReports, generatedAt: generatedAt) + } + + private func makeReport( + rows: [HermesUsageRow], + sources: [HermesUsageSourceReport], + generatedAt: Date) -> HermesUsageReport + { + var overall = HermesUsageAccumulator() + var providers: [UsageProvider: HermesUsageGroupAccumulator] = [:] + var unmapped: [String: HermesUsageGroupAccumulator] = [:] + + for row in rows { + let route = HermesUsageProviderMapping.route( + for: row.billingProvider, + billingBaseURL: row.billingBaseURL) + let apiEquivalentCost = route.flatMap { route in + self.apiEquivalentCost(row: row, route: route, generatedAt: generatedAt) + } + overall.add(row, apiEquivalentCostUSD: apiEquivalentCost) + + if let route { + var group = providers[route.provider] ?? HermesUsageGroupAccumulator() + group.billingProviders.insert(row.billingProvider) + group.add(row, apiEquivalentCostUSD: apiEquivalentCost) + providers[route.provider] = group + } else { + let key = Self.unmappedKey(row.billingProvider) + var group = unmapped[key] ?? HermesUsageGroupAccumulator() + group.add(row, apiEquivalentCostUSD: nil) + unmapped[key] = group + } + } + + let providerReports = providers.map { provider, group in + HermesUsageProviderReport( + provider: provider, + billingProviders: group.billingProviders.sorted(), + summary: group.summary.finalized(), + models: group.namedBreakdowns(group.models), + tasks: group.namedBreakdowns(group.tasks)) + } + .sorted { lhs, rhs in + if lhs.summary.tokens.total != rhs.summary.tokens.total { + return lhs.summary.tokens.total > rhs.summary.tokens.total + } + return lhs.provider.rawValue < rhs.provider.rawValue + } + + let unmappedReports = unmapped.map { provider, group in + HermesUsageUnmappedReport( + billingProvider: provider, + summary: group.summary.finalized(), + models: group.namedBreakdowns(group.models), + tasks: group.namedBreakdowns(group.tasks)) + } + .sorted { $0.billingProvider < $1.billingProvider } + + var warnings = [ + "Hermes state.db stores cumulative per-session rows; this is a current snapshot, not exact daily history.", + "Actual billed cost, Hermes stored estimates, subscription-included usage, " + + "and API-equivalent estimates are separate fields.", + "API-equivalent estimates use standard models.dev rates; cumulative rows cannot reconstruct " + + "per-request tiered pricing.", + ] + if !unmappedReports.isEmpty { + warnings.append("Unmapped billing_provider values are reported but not attributed to a CodexBar provider.") + } + if sources.contains(where: { $0.status != .read }) { + warnings.append("One or more Hermes databases could not be read; totals have partial source coverage.") + } + + return HermesUsageReport( + generatedAt: generatedAt, + sources: sources, + summary: overall.finalized(), + providers: providerReports, + unmapped: unmappedReports, + warnings: warnings) + } + + private func apiEquivalentCost( + row: HermesUsageRow, + route: HermesUsageProviderRoute, + generatedAt: Date) -> Double? + { + guard row.tokens.total > 0, + let lookup = ModelsDevPricingPipeline.lookup( + providerID: route.modelsDevProviderID, + modelID: row.model, + now: generatedAt, + cacheRoot: self.modelsDevCacheRoot) + else { return nil } + + let pricing = lookup.pricing + // Some subscription catalogs intentionally publish all-zero plan rates. That describes + // what the plan charges, not an API-equivalent market price. Keep it unpriced rather than + // turning included usage into a misleading $0 estimate. + guard pricing.inputCostPerToken > 0 + || pricing.outputCostPerToken > 0 + || (pricing.cacheReadInputCostPerToken ?? 0) > 0 + || (pricing.cacheCreationInputCostPerToken ?? 0) > 0 + else { return nil } + let input = Double(row.tokens.input) * pricing.inputCostPerToken + let output = Double(row.tokens.output) * pricing.outputCostPerToken + let cacheRead = Double(row.tokens.cacheRead) + * (pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken) + let cacheWrite = Double(row.tokens.cacheWrite) + * (pricing.cacheCreationInputCostPerToken ?? pricing.inputCostPerToken) + return input + output + cacheRead + cacheWrite + } + + private static func unmappedKey(_ raw: String) -> String { + let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized.isEmpty ? "unknown" : normalized + } +} + +private struct HermesUsageRow { + let sourceLabel: String + let sessionID: String + let model: String + let billingProvider: String + let billingBaseURL: String + let billingMode: String + let task: String + let tokens: HermesUsageTokenCounts + let requests: Int + let estimatedCostUSD: Double + let actualCostUSD: Double + let costStatus: String + let costSource: String + + var isSubscriptionIncluded: Bool { + self.billingMode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "subscription_included" + || self.costStatus.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "included" + } + + var taskDisplayName: String { + let trimmed = self.task.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "agent" : trimmed + } + + var hasHermesEstimate: Bool { + guard !self.isSubscriptionIncluded else { return false } + let status = self.costStatus.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if status == "estimated" || status == "actual" { return true } + let source = self.costSource.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.estimatedCostUSD > 0 && source != "none" + } + + var hasActualCost: Bool { + self.actualCostUSD > 0 + } +} + +private struct HermesUsageAccumulator { + var tokens = HermesUsageTokenCounts() + var requests = 0 + var sessionIDs: Set = [] + var hermesEstimatedCostUSD = 0.0 + var hasHermesEstimatedCost = false + var actualCostUSD = 0.0 + var hasActualCost = false + var subscriptionIncludedTokens = 0 + var subscriptionIncludedRequests = 0 + var apiEquivalentCostUSD = 0.0 + var hasAPIEquivalentCost = false + var apiEquivalentPricedTokens = 0 + var apiEquivalentUnpricedTokens = 0 + + mutating func add(_ row: HermesUsageRow, apiEquivalentCostUSD: Double?) { + self.tokens += row.tokens + self.requests += max(0, row.requests) + if !row.sessionID.isEmpty { + self.sessionIDs.insert("\(row.sourceLabel)\0\(row.sessionID)") + } + if row.hasHermesEstimate { + self.hermesEstimatedCostUSD += max(0, row.estimatedCostUSD) + self.hasHermesEstimatedCost = true + } + if row.hasActualCost { + self.actualCostUSD += max(0, row.actualCostUSD) + self.hasActualCost = true + } + if row.isSubscriptionIncluded { + self.subscriptionIncludedTokens += row.tokens.total + self.subscriptionIncludedRequests += max(0, row.requests) + } + if let apiEquivalentCostUSD { + self.apiEquivalentCostUSD += max(0, apiEquivalentCostUSD) + self.apiEquivalentPricedTokens += row.tokens.total + self.hasAPIEquivalentCost = true + } else { + self.apiEquivalentUnpricedTokens += row.tokens.total + } + } + + func finalized() -> HermesUsageSummary { + HermesUsageSummary( + tokens: self.tokens, + requests: self.requests, + sessions: self.sessionIDs.count, + hermesEstimatedCostUSD: self.hasHermesEstimatedCost ? self.hermesEstimatedCostUSD : nil, + actualCostUSD: self.hasActualCost ? self.actualCostUSD : nil, + subscriptionIncludedTokens: self.subscriptionIncludedTokens, + subscriptionIncludedRequests: self.subscriptionIncludedRequests, + apiEquivalentCostUSD: self.hasAPIEquivalentCost ? self.apiEquivalentCostUSD : nil, + apiEquivalentPricedTokens: self.apiEquivalentPricedTokens, + apiEquivalentUnpricedTokens: self.apiEquivalentUnpricedTokens) + } +} + +private struct HermesUsageGroupAccumulator { + var summary = HermesUsageAccumulator() + var billingProviders: Set = [] + var models: [String: HermesUsageAccumulator] = [:] + var tasks: [String: HermesUsageAccumulator] = [:] + + mutating func add(_ row: HermesUsageRow, apiEquivalentCostUSD: Double?) { + self.summary.add(row, apiEquivalentCostUSD: apiEquivalentCostUSD) + var model = self.models[row.model] ?? HermesUsageAccumulator() + model.add(row, apiEquivalentCostUSD: apiEquivalentCostUSD) + self.models[row.model] = model + var task = self.tasks[row.taskDisplayName] ?? HermesUsageAccumulator() + task.add(row, apiEquivalentCostUSD: apiEquivalentCostUSD) + self.tasks[row.taskDisplayName] = task + } + + func namedBreakdowns(_ values: [String: HermesUsageAccumulator]) -> [HermesUsageNamedBreakdown] { + values.map { name, accumulator in + HermesUsageNamedBreakdown(name: name, summary: accumulator.finalized()) + } + .sorted { lhs, rhs in + if lhs.summary.tokens.total != rhs.summary.tokens.total { + return lhs.summary.tokens.total > rhs.summary.tokens.total + } + return lhs.name < rhs.name + } + } +} + +private struct HermesUsageReadError: Error { + let status: HermesUsageSourceStatus + let message: String +} + +#if canImport(SQLite3) || canImport(CSQLite3) +extension HermesUsageScanner { + private struct SessionAggregate { + let sessionID: String + let model: String + let billingProvider: String + let billingBaseURL: String + let billingMode: String + let tokens: HermesUsageTokenCounts + let requests: Int + let estimatedCostUSD: Double + let actualCostUSD: Double + let costStatus: String + let costSource: String + } + + private struct AttributedTotals { + var tokens = HermesUsageTokenCounts() + var requests = 0 + var estimatedCostUSD = 0.0 + var actualCostUSD = 0.0 + + mutating func add(_ row: HermesUsageRow) { + self.tokens += row.tokens + self.requests += max(0, row.requests) + self.estimatedCostUSD += max(0, row.estimatedCostUSD) + self.actualCostUSD += max(0, row.actualCostUSD) + } + } + + fileprivate static func readRows(databaseURL: URL, sourceLabel: String) throws -> [HermesUsageRow] { + var database: OpaquePointer? + let walExists = FileManager.default.fileExists(atPath: databaseURL.path + "-wal") + let shmExists = FileManager.default.fileExists(atPath: databaseURL.path + "-shm") + guard walExists == shmExists else { + throw HermesUsageReadError( + status: .unreadable, + message: "Hermes WAL sidecars are incomplete; refusing to create or replace them") + } + // Active WAL needs the ordinary read-only connection so committed WAL rows stay visible. + // An idle WAL database without sidecars uses immutable mode, which prevents SQLite from + // recreating -wal/-shm files next to a source CodexBar promises not to modify. + let query = walExists ? "mode=ro" : "mode=ro&immutable=1" + let uri = databaseURL.absoluteURL.absoluteString + "?\(query)" + let openResult = sqlite3_open_v2(uri, &database, SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, nil) + guard openResult == SQLITE_OK else { + let error = Self.readError(database: database, resultCode: openResult) + sqlite3_close(database) + throw error + } + defer { sqlite3_close(database) } + sqlite3_busy_timeout(database, 250) + let queryOnlyResult = sqlite3_exec(database, "PRAGMA query_only = ON", nil, nil, nil) + guard queryOnlyResult == SQLITE_OK else { + throw Self.readError(database: database, resultCode: queryOnlyResult) + } + + let modelColumns = try Self.tableColumns(database: database, table: "session_model_usage") + let sessionColumns = try Self.tableColumns(database: database, table: "sessions") + guard modelColumns != nil || sessionColumns != nil else { + throw HermesUsageReadError( + status: .incompatible, + message: "Neither session_model_usage nor sessions exists") + } + + var rows: [HermesUsageRow] = [] + if let modelColumns { + rows = try Self.readModelUsageRows( + database: database, + columns: modelColumns, + sourceLabel: sourceLabel) + } + + guard let sessionColumns else { return rows } + let sessions = try Self.readSessionAggregates(database: database, columns: sessionColumns) + var attributed: [String: AttributedTotals] = [:] + // Hermes deliberately records auxiliary tasks only in session_model_usage; sessions contains + // main-loop totals. Subtracting auxiliary rows here would erase an equal amount of legacy + // main-loop residual whenever task attribution exists but the main-loop attribution does not. + for row in rows where row.task.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + var totals = attributed[row.sessionID] ?? AttributedTotals() + totals.add(row) + attributed[row.sessionID] = totals + } + for session in sessions { + let totals = attributed[session.sessionID] ?? AttributedTotals() + let residualTokens = HermesUsageTokenCounts.positiveResidual(session.tokens, minus: totals.tokens) + let residualRequests = max(0, session.requests - totals.requests) + let residualEstimatedCost = max(0, session.estimatedCostUSD - totals.estimatedCostUSD) + let residualActualCost = max(0, session.actualCostUSD - totals.actualCostUSD) + guard residualTokens.total > 0 || residualTokens.reasoning > 0 || residualRequests > 0 + || residualEstimatedCost > 0 || residualActualCost > 0 + else { continue } + rows.append(HermesUsageRow( + sourceLabel: sourceLabel, + sessionID: session.sessionID, + model: session.model, + billingProvider: session.billingProvider, + billingBaseURL: session.billingBaseURL, + billingMode: session.billingMode, + task: "", + tokens: residualTokens, + requests: residualRequests, + estimatedCostUSD: residualEstimatedCost, + actualCostUSD: residualActualCost, + costStatus: session.costStatus, + costSource: session.costSource)) + } + return rows + } + + private static func readModelUsageRows( + database: OpaquePointer?, + columns: Set, + sourceLabel: String) throws -> [HermesUsageRow] + { + let required = [ + "session_id", "model", "billing_provider", "api_call_count", "input_tokens", "output_tokens", + "cache_read_tokens", "cache_write_tokens", + ] + guard required.allSatisfy(columns.contains) else { + throw HermesUsageReadError(status: .incompatible, message: "session_model_usage has an unsupported schema") + } + let query = """ + SELECT session_id, model, billing_provider, + \(Self.columnExpression(columns, "billing_base_url", fallback: "''")), + \(Self.columnExpression(columns, "billing_mode", fallback: "''")), + \(Self.columnExpression(columns, "task", fallback: "''")), + api_call_count, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + \(Self.columnExpression(columns, "reasoning_tokens", fallback: "0")), + \(Self.columnExpression(columns, "estimated_cost_usd", fallback: "0")), + \(Self.columnExpression(columns, "actual_cost_usd", fallback: "0")), + \(Self.columnExpression(columns, "cost_status", fallback: "''")), + \(Self.columnExpression(columns, "cost_source", fallback: "''")) + FROM session_model_usage + """ + var statement: OpaquePointer? + let prepare = sqlite3_prepare_v2(database, query, -1, &statement, nil) + guard prepare == SQLITE_OK else { throw Self.readError(database: database, resultCode: prepare) } + defer { sqlite3_finalize(statement) } + + var rows: [HermesUsageRow] = [] + while true { + let step = sqlite3_step(statement) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { throw Self.readError(database: database, resultCode: step) } + rows.append(HermesUsageRow( + sourceLabel: sourceLabel, + sessionID: Self.text(statement, 0), + model: Self.nonEmptyText(statement, 1) ?? "unknown", + billingProvider: Self.text(statement, 2), + billingBaseURL: Self.text(statement, 3), + billingMode: Self.text(statement, 4), + task: Self.text(statement, 5), + tokens: HermesUsageTokenCounts( + input: Self.integer(statement, 7), + output: Self.integer(statement, 8), + cacheRead: Self.integer(statement, 9), + cacheWrite: Self.integer(statement, 10), + reasoning: Self.integer(statement, 11)), + requests: Self.integer(statement, 6), + estimatedCostUSD: max(0, sqlite3_column_double(statement, 12)), + actualCostUSD: max(0, sqlite3_column_double(statement, 13)), + costStatus: Self.text(statement, 14), + costSource: Self.text(statement, 15))) + } + return rows + } + + private static func readSessionAggregates( + database: OpaquePointer?, + columns: Set) throws -> [SessionAggregate] + { + let required = ["id", "model", "billing_provider", "api_call_count", "input_tokens", "output_tokens"] + guard required.allSatisfy(columns.contains) else { + throw HermesUsageReadError(status: .incompatible, message: "sessions has an unsupported usage schema") + } + let query = """ + SELECT id, model, billing_provider, + \(Self.columnExpression(columns, "billing_base_url", fallback: "''")), + \(Self.columnExpression(columns, "billing_mode", fallback: "''")), + api_call_count, input_tokens, output_tokens, + \(Self.columnExpression(columns, "cache_read_tokens", fallback: "0")), + \(Self.columnExpression(columns, "cache_write_tokens", fallback: "0")), + \(Self.columnExpression(columns, "reasoning_tokens", fallback: "0")), + \(Self.columnExpression(columns, "estimated_cost_usd", fallback: "0")), + \(Self.columnExpression(columns, "actual_cost_usd", fallback: "0")), + \(Self.columnExpression(columns, "cost_status", fallback: "''")), + \(Self.columnExpression(columns, "cost_source", fallback: "''")) + FROM sessions + """ + var statement: OpaquePointer? + let prepare = sqlite3_prepare_v2(database, query, -1, &statement, nil) + guard prepare == SQLITE_OK else { throw Self.readError(database: database, resultCode: prepare) } + defer { sqlite3_finalize(statement) } + + var rows: [SessionAggregate] = [] + while true { + let step = sqlite3_step(statement) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { throw Self.readError(database: database, resultCode: step) } + rows.append(SessionAggregate( + sessionID: Self.text(statement, 0), + model: Self.nonEmptyText(statement, 1) ?? "unknown", + billingProvider: Self.text(statement, 2), + billingBaseURL: Self.text(statement, 3), + billingMode: Self.text(statement, 4), + tokens: HermesUsageTokenCounts( + input: Self.integer(statement, 6), + output: Self.integer(statement, 7), + cacheRead: Self.integer(statement, 8), + cacheWrite: Self.integer(statement, 9), + reasoning: Self.integer(statement, 10)), + requests: Self.integer(statement, 5), + estimatedCostUSD: max(0, sqlite3_column_double(statement, 11)), + actualCostUSD: max(0, sqlite3_column_double(statement, 12)), + costStatus: Self.text(statement, 13), + costSource: Self.text(statement, 14))) + } + return rows + } + + private static func tableColumns(database: OpaquePointer?, table: String) throws -> Set? { + var existsStatement: OpaquePointer? + let existsQuery = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1" + var result = sqlite3_prepare_v2(database, existsQuery, -1, &existsStatement, nil) + guard result == SQLITE_OK else { throw Self.readError(database: database, resultCode: result) } + defer { sqlite3_finalize(existsStatement) } + sqlite3_bind_text(existsStatement, 1, table, -1, hermesSQLiteTransient) + result = sqlite3_step(existsStatement) + if result == SQLITE_DONE { return nil } + guard result == SQLITE_ROW else { throw Self.readError(database: database, resultCode: result) } + + var statement: OpaquePointer? + let query = "PRAGMA table_info('\(table)')" + result = sqlite3_prepare_v2(database, query, -1, &statement, nil) + guard result == SQLITE_OK else { throw Self.readError(database: database, resultCode: result) } + defer { sqlite3_finalize(statement) } + var columns: Set = [] + while true { + result = sqlite3_step(statement) + if result == SQLITE_DONE { break } + guard result == SQLITE_ROW else { throw Self.readError(database: database, resultCode: result) } + columns.insert(Self.text(statement, 1)) + } + return columns + } + + private static func columnExpression(_ columns: Set, _ column: String, fallback: String) -> String { + columns.contains(column) ? "COALESCE(\(column), \(fallback))" : fallback + } + + private static func integer(_ statement: OpaquePointer?, _ index: Int32) -> Int { + let value = max(Int64(0), sqlite3_column_int64(statement, index)) + return value > Int64(Int.max) ? Int.max : Int(value) + } + + private static func text(_ statement: OpaquePointer?, _ index: Int32) -> String { + guard sqlite3_column_type(statement, index) != SQLITE_NULL, + let value = sqlite3_column_text(statement, index) + else { return "" } + return String(cString: value) + } + + private static func nonEmptyText(_ statement: OpaquePointer?, _ index: Int32) -> String? { + let value = Self.text(statement, index).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private static func readError(database: OpaquePointer?, resultCode: Int32) -> HermesUsageReadError { + let primaryCode = resultCode & 0xFF + let status: HermesUsageSourceStatus = switch primaryCode { + case SQLITE_BUSY, SQLITE_LOCKED: .locked + case SQLITE_CORRUPT, SQLITE_NOTADB: .corrupt + default: .unreadable + } + let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "SQLite error \(resultCode)" + return HermesUsageReadError(status: status, message: message) + } +} +#else +extension HermesUsageScanner { + fileprivate static func readRows(databaseURL _: URL, sourceLabel _: String) throws -> [HermesUsageRow] { + throw HermesUsageReadError(status: .incompatible, message: "SQLite support is unavailable") + } +} +#endif diff --git a/Tests/CodexBarTests/CLIHermesUsageTests.swift b/Tests/CodexBarTests/CLIHermesUsageTests.swift new file mode 100644 index 0000000000..a0be803c1a --- /dev/null +++ b/Tests/CodexBarTests/CLIHermesUsageTests.swift @@ -0,0 +1,207 @@ +import CodexBarCore +import Commander +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLIHermesUsageTests { + @Test + func `hermes usage command is registered and parses source options`() throws { + let program = Program(descriptors: CodexBarCLI.commandDescriptors()) + let invocation = try program.resolve(argv: [ + "hermes-usage", + "--database", "/tmp/one.db,/tmp/two.db", + "--provider", "codex", + "--json", + "--pretty", + "--refresh-pricing", + ]) + + #expect(invocation.path == ["hermes-usage"]) + #expect(invocation.parsedValues.options["database"] == ["/tmp/one.db,/tmp/two.db"]) + #expect(invocation.parsedValues.options["provider"] == ["codex"]) + #expect(invocation.parsedValues.flags.contains("jsonShortcut")) + #expect(invocation.parsedValues.flags.contains("pretty")) + #expect(invocation.parsedValues.flags.contains("refreshPricing")) + } + + @Test + func `provider filter accepts every mapped CodexBar provider`() throws { + let signature = CodexBarCLI._hermesUsageSignatureForTesting() + let parser = CommandParser(signature: signature) + let providers = Set(HermesUsageProviderMapping.supportedBillingProviders.compactMap { + HermesUsageProviderMapping.route(for: $0)?.provider + }) + + for provider in providers { + let parsed = try parser.parse(arguments: ["--provider", provider.rawValue]) + #expect(try CodexBarCLI.decodeHermesUsageProvider(from: parsed) == provider) + } + } + + @Test + func `provider filter rejects unsupported provider`() throws { + let parser = CommandParser(signature: CodexBarCLI._hermesUsageSignatureForTesting()) + let parsed = try parser.parse(arguments: ["--provider", "cursor"]) + + #expect(throws: CLIArgumentError.self) { + try CodexBarCLI.decodeHermesUsageProvider(from: parsed) + } + } + + @Test + func `database option expands tilde and deduplicates paths`() throws { + let parser = CommandParser(signature: CodexBarCLI._hermesUsageSignatureForTesting()) + let parsed = try parser.parse(arguments: [ + "--database", "~/one.db,/tmp/two.db,~/one.db", + ]) + + let urls = CodexBarCLI.decodeHermesUsageDatabaseURLs( + from: parsed, + homeDirectory: URL(fileURLWithPath: "/home/test")) + + #expect(urls.map(\.path) == ["/home/test/one.db", "/tmp/two.db"]) + } + + @Test + func `provider filtering recomputes the top level summary`() throws { + let report = Self.report() + + let filtered = try CodexBarCLI.filterHermesUsageReport(report, provider: .codex) + + #expect(filtered.providers.map(\.provider) == [.codex]) + #expect(filtered.summary.tokens.total == 160) + #expect(filtered.summary.subscriptionIncludedTokens == 160) + #expect(filtered.summary.actualCostUSD == nil) + #expect(filtered.unmapped.isEmpty) + #expect(filtered.sources == report.sources) + } + + @Test + func `source validation fails when every database is unreadable`() throws { + let sources = [ + HermesUsageSourceReport(label: "missing", databasePath: "/tmp/missing.db", status: .missing), + HermesUsageSourceReport(label: "broken", databasePath: "/tmp/broken.db", status: .corrupt), + ] + + #expect(throws: HermesUsageCLIError.noReadableSources(details: "missing: missing, broken: corrupt")) { + try CodexBarCLI.validateHermesUsageSources(sources) + } + } + + @Test + func `source validation permits an explicit partial report`() throws { + let sources = [ + HermesUsageSourceReport(label: "work", databasePath: "/tmp/work.db", status: .read), + HermesUsageSourceReport(label: "broken", databasePath: "/tmp/broken.db", status: .locked), + ] + + try CodexBarCLI.validateHermesUsageSources(sources) + } + + @Test + func `text output keeps billed included and equivalent costs distinct`() { + let output = CodexBarCLI.renderHermesUsageText(Self.report(), useColor: false) + .replacingOccurrences(of: "\u{00A0}", with: " ") + + #expect(output.contains("Hermes local usage snapshot")) + #expect(output.contains("Actual billed: $1.00")) + #expect(output.contains("Hermes estimate: $1.25")) + #expect(output.contains("API-equivalent: ~$0.00")) + #expect(output.contains("Included in subscription: 160 tokens · 3 requests")) + #expect(output.contains("Unmapped routes: auto")) + #expect(output.contains("not exact daily history")) + #expect(!output.contains("Total billed: $0.00")) + } + + @Test + func `json output preserves all cost semantics`() throws { + let json = try #require(CodexBarCLI.encodeJSON(Self.report(), pretty: false)) + let data = try #require(json.data(using: .utf8)) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let summary = try #require(object["summary"] as? [String: Any]) + + #expect(summary["actualCostUSD"] as? Double == 1.0) + #expect(summary["hermesEstimatedCostUSD"] as? Double == 1.25) + #expect(summary["apiEquivalentCostUSD"] as? Double == 0.00044815) + #expect(summary["subscriptionIncludedTokens"] as? Int == 160) + } + + private static func report() -> HermesUsageReport { + let codexSummary = HermesUsageSummary( + tokens: HermesUsageTokenCounts(input: 103, output: 22, cacheRead: 30, cacheWrite: 5, reasoning: 10), + requests: 3, + sessions: 1, + hermesEstimatedCostUSD: nil, + actualCostUSD: nil, + subscriptionIncludedTokens: 160, + subscriptionIncludedRequests: 3, + apiEquivalentCostUSD: 0.00016075, + apiEquivalentPricedTokens: 160, + apiEquivalentUnpricedTokens: 0) + let claudeSummary = HermesUsageSummary( + tokens: HermesUsageTokenCounts(input: 40, output: 10, cacheRead: 8, cacheWrite: 4, reasoning: 6), + requests: 1, + sessions: 1, + hermesEstimatedCostUSD: 1.25, + actualCostUSD: 1.0, + subscriptionIncludedTokens: 0, + subscriptionIncludedRequests: 0, + apiEquivalentCostUSD: 0.0002874, + apiEquivalentPricedTokens: 62, + apiEquivalentUnpricedTokens: 0) + let unmappedSummary = HermesUsageSummary( + tokens: HermesUsageTokenCounts(input: 9, output: 3), + requests: 1, + sessions: 1, + hermesEstimatedCostUSD: nil, + actualCostUSD: nil, + subscriptionIncludedTokens: 0, + subscriptionIncludedRequests: 0, + apiEquivalentCostUSD: nil, + apiEquivalentPricedTokens: 0, + apiEquivalentUnpricedTokens: 12) + let overall = HermesUsageSummary( + tokens: HermesUsageTokenCounts(input: 152, output: 35, cacheRead: 38, cacheWrite: 9, reasoning: 16), + requests: 5, + sessions: 3, + hermesEstimatedCostUSD: 1.25, + actualCostUSD: 1.0, + subscriptionIncludedTokens: 160, + subscriptionIncludedRequests: 3, + apiEquivalentCostUSD: 0.00044815, + apiEquivalentPricedTokens: 222, + apiEquivalentUnpricedTokens: 12) + return HermesUsageReport( + generatedAt: Date(timeIntervalSince1970: 1_800_000_000), + sources: [ + HermesUsageSourceReport(label: "work", databasePath: "/tmp/state.db", status: .read), + ], + summary: overall, + providers: [ + HermesUsageProviderReport( + provider: .codex, + billingProviders: ["openai-codex"], + summary: codexSummary, + models: [], + tasks: []), + HermesUsageProviderReport( + provider: .claude, + billingProviders: ["anthropic"], + summary: claudeSummary, + models: [], + tasks: []), + ], + unmapped: [ + HermesUsageUnmappedReport( + billingProvider: "auto", + summary: unmappedSummary, + models: [], + tasks: []), + ], + warnings: [ + "Hermes state.db stores cumulative per-session rows; this is a current snapshot, " + + "not exact daily history.", + ]) + } +} diff --git a/Tests/CodexBarTests/HermesUsageScannerTests.swift b/Tests/CodexBarTests/HermesUsageScannerTests.swift new file mode 100644 index 0000000000..565fe078ed --- /dev/null +++ b/Tests/CodexBarTests/HermesUsageScannerTests.swift @@ -0,0 +1,509 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif +import Testing +@testable import CodexBarCore + +#if canImport(SQLite3) || canImport(CSQLite3) +struct HermesUsageScannerTests { + @Test + func `provider mapping covers the shared Hermes and CodexBar routes`() { + let expected: [String: UsageProvider] = [ + "alibaba": .qwencloud, + "alibaba-coding-plan": .alibaba, + "anthropic": .claude, + "bedrock": .bedrock, + "copilot": .copilot, + "copilot-acp": .copilot, + "deepinfra": .deepinfra, + "deepseek": .deepseek, + "fireworks": .fireworks, + "gemini": .gemini, + "kilocode": .kilo, + "kimi-coding": .kimi, + "kimi-coding-cn": .moonshot, + "minimax": .minimax, + "minimax-cn": .minimax, + "minimax-oauth": .minimax, + "ollama-cloud": .ollama, + "openai-api": .openai, + "openai-codex": .codex, + "opencode-go": .opencodego, + "opencode-zen": .opencode, + "openrouter": .openrouter, + "qwen-oauth": .qwencloud, + "stepfun": .stepfun, + "vertex": .vertexai, + "xai": .xai, + "xai-oauth": .grok, + "xiaomi": .mimo, + "zai": .zai, + ] + + #expect(HermesUsageProviderMapping.supportedBillingProviders == expected.keys.sorted()) + for (billingProvider, provider) in expected { + #expect(HermesUsageProviderMapping.route(for: billingProvider)?.provider == provider) + } + #expect(HermesUsageProviderMapping.route(for: " OPENAI-CODEX ")?.provider == .codex) + + for ambiguous in ["", "auto", "custom", "moa", "azure-foundry", "unknown-provider"] { + #expect(HermesUsageProviderMapping.route(for: ambiguous) == nil) + } + + #expect(HermesUsageProviderMapping.route(for: "alibaba-coding-plan")?.modelsDevProviderID == "alibaba") + #expect(HermesUsageProviderMapping.route(for: "alibaba")?.provider == .qwencloud) + #expect(HermesUsageProviderMapping.route(for: "alibaba-coding-plan")?.provider == .alibaba) + #expect(HermesUsageProviderMapping.route(for: "kimi-coding")?.modelsDevProviderID == "moonshotai") + #expect(HermesUsageProviderMapping.route(for: "kimi-coding-cn")?.modelsDevProviderID == "moonshotai-cn") + #expect(HermesUsageProviderMapping.route(for: "minimax-oauth")?.modelsDevProviderID == "minimax") + } + + @Test + func `resolved base URL distinguishes Kimi Moonshot MiniMax and StepFun regions`() { + #expect(HermesUsageProviderMapping.route( + for: "kimi-coding", + billingBaseURL: "https://api.kimi.com/coding")?.provider == .kimi) + #expect(HermesUsageProviderMapping.route( + for: "kimi-coding", + billingBaseURL: "https://api.moonshot.ai/v1") == .init( + provider: .moonshot, + modelsDevProviderID: "moonshotai")) + #expect(HermesUsageProviderMapping.route( + for: "kimi-coding-cn", + billingBaseURL: "https://api.moonshot.cn/v1") == .init( + provider: .moonshot, + modelsDevProviderID: "moonshotai-cn")) + #expect(HermesUsageProviderMapping.route( + for: "minimax-oauth", + billingBaseURL: "https://api.minimax.io/anthropic")?.modelsDevProviderID == "minimax") + #expect(HermesUsageProviderMapping.route( + for: "minimax-oauth", + billingBaseURL: "https://api.minimaxi.com/anthropic")?.modelsDevProviderID == "minimax-cn") + #expect(HermesUsageProviderMapping.route( + for: "stepfun", + billingBaseURL: "https://api.stepfun.ai/step_plan/v1")?.modelsDevProviderID == "stepfun-ai") + #expect(HermesUsageProviderMapping.route( + for: "stepfun", + billingBaseURL: "https://api.stepfun.com/step_plan/v1")?.modelsDevProviderID == "stepfun") + } + + @Test + func `mapping audit classifies every canonical Hermes provider`() { + // hermes_cli.models.CANONICAL_PROVIDERS, audited 2026-08-12. Virtual routers, + // custom endpoints, and providers without a first-party CodexBar equivalent stay unmapped. + let auditedCanonicalProviders: Set = [ + "actual", "ai-gateway", "alibaba", "alibaba-coding-plan", "anthropic", "arcee", + "azure-foundry", "bedrock", "copilot", "copilot-acp", "custom", "deepinfra", "deepseek", + "fireworks", "gemini", "gmi", "huggingface", "kilocode", "kimi-coding", "kimi-coding-cn", + "lmstudio", "minimax", "minimax-cn", "minimax-oauth", "moa", "nous", "novita", "nvidia", + "ollama-cloud", "openai-api", "openai-codex", "opencode-go", "opencode-zen", "openrouter", + "qwen-oauth", "stepfun", "tencent-tokenhub", "upstage", "vertex", "xai", "xai-oauth", + "xiaomi", "zai", + ] + let intentionallyUnmapped: Set = [ + "actual", "ai-gateway", "arcee", "azure-foundry", "custom", "gmi", "huggingface", "lmstudio", + "moa", "nous", "novita", "nvidia", "tencent-tokenhub", "upstage", + ] + let mapped = Set(HermesUsageProviderMapping.supportedBillingProviders) + + #expect(mapped.isDisjoint(with: intentionallyUnmapped)) + #expect(mapped.union(intentionallyUnmapped) == auditedCanonicalProviders) + } + + @Test + func `scanner reads active WAL and keeps token and cost semantics separate`() throws { + let fixture = try HermesUsageFixture() + defer { fixture.remove() } + try fixture.seedCurrentSchema() + try Self.seedPricing(cacheRoot: fixture.cacheRoot) + + #expect(FileManager.default.fileExists(atPath: fixture.databaseURL.path + "-wal")) + let before = try fixture.databaseShape() + let report = try HermesUsageScanner(modelsDevCacheRoot: fixture.cacheRoot).scan( + databaseURLs: [fixture.databaseURL], + generatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + let after = try fixture.databaseShape() + + #expect(before == after) + #expect(report.sources.count == 1) + #expect(report.sources[0].status == .read) + #expect(report.summary.tokens.total == 265) + #expect(report.summary.requests == 8) + #expect(report.summary.tokens.reasoning == 16) + #expect(report.summary.hermesEstimatedCostUSD == 1.5) + #expect(report.summary.actualCostUSD == 1.2) + #expect(report.summary.subscriptionIncludedTokens == 160) + #expect(report.summary.subscriptionIncludedRequests == 3) + #expect(report.summary.apiEquivalentPricedTokens == 222) + #expect(report.summary.apiEquivalentUnpricedTokens == 43) + #expect(report.summary.apiEquivalentCostUSD != nil) + + let codex = try #require(report.providers.first { $0.provider == .codex }) + #expect(codex.summary.tokens == HermesUsageTokenCounts( + input: 103, + output: 22, + cacheRead: 30, + cacheWrite: 5, + reasoning: 10)) + #expect(codex.summary.subscriptionIncludedTokens == 160) + #expect(codex.summary.actualCostUSD == nil) + #expect(codex.summary.hermesEstimatedCostUSD == nil) + #expect(abs((codex.summary.apiEquivalentCostUSD ?? 0) - 0.00016075) < 0.000000001) + #expect(codex.models.map(\.name) == ["gpt-5.4"]) + #expect(codex.models[0].summary.tokens.total == 160) + #expect(codex.tasks.map(\.name) == ["agent", "title_generation"]) + #expect(codex.tasks.map(\.summary.tokens.total) == [155, 5]) + + let claude = try #require(report.providers.first { $0.provider == .claude }) + #expect(claude.summary.tokens.total == 62) + #expect(claude.summary.hermesEstimatedCostUSD == 1.25) + #expect(claude.summary.actualCostUSD == 1.0) + #expect(abs((claude.summary.apiEquivalentCostUSD ?? 0) - 0.0002874) < 0.000000001) + + let deepSeek = try #require(report.providers.first { $0.provider == .deepseek }) + #expect(deepSeek.summary.tokens.total == 10) + #expect(deepSeek.summary.hermesEstimatedCostUSD == 0.25) + #expect(deepSeek.summary.actualCostUSD == 0.2) + #expect(deepSeek.summary.apiEquivalentCostUSD == nil) + #expect(deepSeek.summary.apiEquivalentUnpricedTokens == 10) + + #expect(report.unmapped.map(\.billingProvider) == ["auto", "custom"]) + #expect(report.unmapped.map(\.summary.tokens.total) == [24, 9]) + let automatic = try #require(report.unmapped.first { $0.billingProvider == "auto" }) + #expect(automatic.tasks.map(\.name) == ["agent", "compression"]) + #expect(automatic.tasks.map(\.summary.tokens.total) == [12, 12]) + #expect(report.unmapped.first { $0.billingProvider == "custom" }?.summary.hermesEstimatedCostUSD == nil) + #expect(report.warnings.contains { $0.contains("not exact daily history") }) + } + + @Test + func `legacy session aggregate is used only as a positive residual`() throws { + let fixture = try HermesUsageFixture() + defer { fixture.remove() } + try fixture.seedLegacySchemaWithoutTask() + + let report = try HermesUsageScanner().scan(databaseURLs: [fixture.databaseURL]) + let codex = try #require(report.providers.first { $0.provider == .codex }) + + #expect(codex.summary.tokens.total == 20) + #expect(codex.summary.requests == 2) + #expect(codex.tasks.map(\.name) == ["agent"]) + #expect(report.summary.tokens.total == 20) + } + + @Test + func `idle WAL database is read without recreating sidecars`() throws { + let fixture = try HermesUsageFixture() + try fixture.seedLegacySchemaWithoutTask() + fixture.closeAndRemoveSidecars() + defer { fixture.remove() } + + #expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path + "-wal")) + #expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path + "-shm")) + + let report = try HermesUsageScanner().scan(databaseURLs: [fixture.databaseURL]) + + #expect(report.sources.first?.status == .read) + #expect(report.summary.tokens.total == 20) + #expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path + "-wal")) + #expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path + "-shm")) + } + + @Test + func `all zero plan pricing stays unpriced instead of becoming zero API equivalent`() throws { + let fixture = try HermesUsageFixture() + defer { fixture.remove() } + try fixture.seedCurrentSchema() + try Self.seedZeroPricing(cacheRoot: fixture.cacheRoot) + + let report = try HermesUsageScanner(modelsDevCacheRoot: fixture.cacheRoot).scan( + databaseURLs: [fixture.databaseURL], + generatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + let codex = try #require(report.providers.first { $0.provider == .codex }) + + #expect(codex.summary.apiEquivalentCostUSD == nil) + #expect(codex.summary.apiEquivalentPricedTokens == 0) + #expect(codex.summary.apiEquivalentUnpricedTokens == 160) + } + + @Test + func `missing database is reported without being created`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("HermesUsageScannerMissing-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let missing = root.appendingPathComponent("state.db") + + let report = try HermesUsageScanner().scan(databaseURLs: [missing]) + + #expect(report.sources.count == 1) + #expect(report.sources[0].status == .missing) + #expect(report.providers.isEmpty) + #expect(!FileManager.default.fileExists(atPath: missing.path)) + } + + @Test + func `discovery finds default and named profiles but ignores snapshots`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("HermesUsageDiscovery-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let root = home.appendingPathComponent(".hermes", isDirectory: true) + let work = root.appendingPathComponent("profiles/work", isDirectory: true) + let sport = root.appendingPathComponent("profiles/sport", isDirectory: true) + let snapshot = work.appendingPathComponent("state-snapshots/old", isDirectory: true) + for directory in [root, work, sport, snapshot] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: directory.appendingPathComponent("state.db").path, contents: Data()) + } + + let discovered = HermesUsageDatabaseDiscovery.discover( + environment: ["HERMES_HOME": work.path], + homeDirectory: home) + + #expect(discovered.map(\.label) == ["default", "sport", "work"]) + #expect(!discovered.contains { $0.databaseURL.path.contains("state-snapshots") }) + } + + private static func seedPricing(cacheRoot: URL) throws { + let catalog = ModelsDevCatalog(providers: [ + "openai": ModelsDevProvider( + id: "openai", + name: "OpenAI", + models: [ + "gpt-5.4": ModelsDevModel( + id: "gpt-5.4", + name: "GPT-5.4", + cost: ModelsDevCost( + input: 1, + output: 2, + cacheRead: 0.25, + cacheWrite: 1.25, + contextOver200K: nil), + limit: ModelsDevLimit(context: nil)), + ]), + "anthropic": ModelsDevProvider( + id: "anthropic", + name: "Anthropic", + models: [ + "claude-sonnet-4-6": ModelsDevModel( + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + cost: ModelsDevCost( + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + contextOver200K: nil), + limit: ModelsDevLimit(context: nil)), + ]), + ]) + #expect(ModelsDevCache.save( + catalog: catalog, + fetchedAt: Date(timeIntervalSince1970: 1_800_000_000), + cacheRoot: cacheRoot)) + } + + private static func seedZeroPricing(cacheRoot: URL) throws { + let catalog = ModelsDevCatalog(providers: [ + "openai": ModelsDevProvider( + id: "openai", + name: "OpenAI plan", + models: [ + "gpt-5.4": ModelsDevModel( + id: "gpt-5.4", + name: "GPT-5.4", + cost: ModelsDevCost( + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + contextOver200K: nil), + limit: ModelsDevLimit(context: nil)), + ]), + ]) + #expect(ModelsDevCache.save( + catalog: catalog, + fetchedAt: Date(timeIntervalSince1970: 1_800_000_000), + cacheRoot: cacheRoot)) + } +} + +private final class HermesUsageFixture { + enum FixtureError: Error { + case open(Int32) + case exec(Int32, String) + } + + let root: URL + let databaseURL: URL + let cacheRoot: URL + private var database: OpaquePointer? + + init() throws { + self.root = FileManager.default.temporaryDirectory + .appendingPathComponent("HermesUsageScannerTests-\(UUID().uuidString)", isDirectory: true) + self.databaseURL = self.root.appendingPathComponent("state.db") + self.cacheRoot = self.root.appendingPathComponent("cache", isDirectory: true) + try FileManager.default.createDirectory(at: self.root, withIntermediateDirectories: true) + let result = sqlite3_open_v2( + self.databaseURL.path, + &self.database, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + nil) + guard result == SQLITE_OK else { throw FixtureError.open(result) } + } + + deinit { + if let database { + sqlite3_close_v2(database) + } + } + + func remove() { + if let database { + sqlite3_close_v2(database) + self.database = nil + } + try? FileManager.default.removeItem(at: self.root) + } + + func closeAndRemoveSidecars() { + if let database { + sqlite3_wal_checkpoint_v2(database, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) + sqlite3_close_v2(database) + self.database = nil + } + try? FileManager.default.removeItem(atPath: self.databaseURL.path + "-wal") + try? FileManager.default.removeItem(atPath: self.databaseURL.path + "-shm") + } + + func seedCurrentSchema() throws { + try self.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;") + try self.exec(Self.sessionsSchema) + try self.exec(Self.modelUsageSchemaWithTask) + try self.exec(""" + INSERT INTO sessions VALUES + ('s1','gpt-5.4','openai-codex','https://api.openai.com','subscription_included',2,100,20,30,5,10,0,0,'included','none'), + ('s2','claude-sonnet-4-6','anthropic','https://api.anthropic.com','official_docs_snapshot',1,40,10,8,4,6,1.25,1.0,'estimated','provider'), + ('s3','deepseek-v4-pro','deepseek','https://api.deepseek.com','official_models_api',1,7,3,0,0,0,0.25,0.2,'estimated','provider'), + ('s4','gpt-5.4','auto','', '',1,9,3,0,0,0,0,0,NULL,NULL), + ('s5','gemma4:12b-mlx','custom','http://localhost:1234','',1,8,1,0,0,0,9.5,0,'unknown','none'); + """) + try self.exec(""" + INSERT INTO session_model_usage VALUES + ('s1','gpt-5.4','openai-codex','https://api.openai.com','subscription_included','',2,100,20,30,5,10,0,0,'included','none',1,2), + ('s1','gpt-5.4','openai-codex','https://api.openai.com','subscription_included','title_generation',1,3,2,0,0,0,0,0,'included','none',1,2), + ('s2','claude-sonnet-4-6','anthropic','https://api.anthropic.com','official_docs_snapshot','',1,40,10,8,4,6,1.25,1.0,'estimated','provider',1,2), + ('s4','gpt-5.4','auto','','','compression',1,9,3,0,0,0,0,0,NULL,NULL,1,2), + ('s5','gemma4:12b-mlx','custom','http://localhost:1234','','',1,8,1,0,0,0,9.5,0,'unknown','none',1,2); + """) + } + + func seedLegacySchemaWithoutTask() throws { + try self.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;") + try self.exec(Self.sessionsSchema) + try self.exec(Self.modelUsageSchemaWithoutTask) + try self.exec(""" + INSERT INTO sessions VALUES + ('legacy','gpt-5.4','openai-codex','https://api.openai.com','subscription_included',2,10,5,4,1,3,0,0,'included','none'); + INSERT INTO session_model_usage VALUES + ('legacy','gpt-5.4','openai-codex','https://api.openai.com','subscription_included',1,6,3,2,0,2,0,0,'included','none',1,2); + """) + } + + func databaseShape() throws -> String { + var statement: OpaquePointer? + let sql = """ + SELECT (SELECT COUNT(*) FROM sessions), + (SELECT COUNT(*) FROM session_model_usage), + (SELECT COUNT(*) FROM sqlite_master) + """ + let prepare = sqlite3_prepare_v2(self.database, sql, -1, &statement, nil) + guard prepare == SQLITE_OK else { throw FixtureError.exec(prepare, sql) } + defer { sqlite3_finalize(statement) } + let step = sqlite3_step(statement) + guard step == SQLITE_ROW else { throw FixtureError.exec(step, sql) } + return [0, 1, 2] + .map { String(sqlite3_column_int64(statement, Int32($0))) } + .joined(separator: ":") + } + + private func exec(_ sql: String) throws { + let result = sqlite3_exec(self.database, sql, nil, nil, nil) + guard result == SQLITE_OK else { + let message = self.database.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown" + throw FixtureError.exec(result, message) + } + } + + private static let sessionsSchema = """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + model TEXT, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + api_call_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + estimated_cost_usd REAL NOT NULL DEFAULT 0, + actual_cost_usd REAL NOT NULL DEFAULT 0, + cost_status TEXT, + cost_source TEXT + ); + """ + + private static let modelUsageSchemaWithTask = """ + CREATE TABLE session_model_usage ( + session_id TEXT NOT NULL, + model TEXT NOT NULL, + billing_provider TEXT NOT NULL DEFAULT '', + billing_base_url TEXT NOT NULL DEFAULT '', + billing_mode TEXT NOT NULL DEFAULT '', + task TEXT NOT NULL DEFAULT '', + api_call_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + estimated_cost_usd REAL NOT NULL DEFAULT 0, + actual_cost_usd REAL NOT NULL DEFAULT 0, + cost_status TEXT, + cost_source TEXT, + first_seen REAL, + last_seen REAL, + PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task) + ); + """ + + private static let modelUsageSchemaWithoutTask = """ + CREATE TABLE session_model_usage ( + session_id TEXT NOT NULL, + model TEXT NOT NULL, + billing_provider TEXT NOT NULL DEFAULT '', + billing_base_url TEXT NOT NULL DEFAULT '', + billing_mode TEXT NOT NULL DEFAULT '', + api_call_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + estimated_cost_usd REAL NOT NULL DEFAULT 0, + actual_cost_usd REAL NOT NULL DEFAULT 0, + cost_status TEXT, + cost_source TEXT, + first_seen REAL, + last_seen REAL, + PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode) + ); + """ +} +#endif diff --git a/docs/cli.md b/docs/cli.md index c910a4ae49..f9ed8cd10b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -56,6 +56,13 @@ See `docs/configuration.md` for the schema. - `--format text|json` (default: text). - `--refresh` ignores cached scans. - `--provider-native-only` is experimental and excludes pi and OMP session mirrors from Claude and Codex history. +- `codexbar hermes-usage` reads provider/model/task token attribution from local Hermes Agent `state.db` files. + - Discovery covers the default Hermes home and named profiles; `--database ` sets an explicit scope. + - SQLite remains read-only and WAL-aware. No Hermes OAuth token, provider API key, or browser cookie is read. + - Actual billed cost, Hermes estimates, subscription-included usage, and models.dev API-equivalent estimates are + separate JSON fields; unknown values stay `null`, never fabricated `$0`. + - The report is a cumulative current snapshot, not exact daily history, and is not auto-merged with provider-native + billing sources because that could count the same API call twice. See `docs/hermes-usage.md`. - `codexbar cards` prints a one-shot usage snapshot as a responsive terminal card grid. - Reuses the same provider, source, account, credits, and status flags as `codexbar usage`. - Account lines and plan badges are included in the card grid by default. @@ -213,6 +220,7 @@ codexbar cost # cost usage (default 30-day window + today) codexbar cost --days 90 # choose a 1...365 day cost window codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty +codexbar hermes-usage --provider codex --json --pretty codexbar guard --provider codex --min-remaining 20 --window weekly --json codexbar cost --provider cursor # Cursor dashboard cost (API-rate + Cursor-metered) codexbar dashboard | jq '.providers[] | {id, windows, error}' diff --git a/docs/hermes-usage.md b/docs/hermes-usage.md new file mode 100644 index 0000000000..872bea2a47 --- /dev/null +++ b/docs/hermes-usage.md @@ -0,0 +1,91 @@ +--- +summary: "Read local Hermes Agent token attribution from CodexBar." +read_when: + - "You use Hermes Agent and want provider/model/task token totals." + - "Changing Hermes state.db parsing or provider mapping." + - "Reviewing billed, included, or API-equivalent cost semantics." +--- + +# Hermes Agent local usage + +`codexbar hermes-usage` reads Hermes Agent's local SQLite attribution data without importing Hermes credentials, +calling Nous Portal, or scraping a browser dashboard. + +```bash +codexbar hermes-usage +codexbar hermes-usage --provider codex --json --pretty +codexbar hermes-usage --database ~/.hermes/profiles/work/state.db --refresh-pricing +``` + +Without `--database`, CodexBar discovers `~/.hermes/state.db` plus `~/.hermes/profiles/*/state.db`. Explicit +comma-separated database paths replace discovery. State snapshots and arbitrary recursive matches are not scanned. + +## Safety and accounting contract + +- SQLite opens with `SQLITE_OPEN_READONLY | SQLITE_OPEN_URI`, `mode=ro`, and `PRAGMA query_only = ON`. The normal + connection remains WAL-aware, so committed rows in a live `state.db-wal` stay visible. +- The scanner reads `session_model_usage` and reconciles only positive residuals from `sessions`. This matches Hermes' + own Insights behavior for legacy data and absolute cumulative updates without counting attributed route deltas twice. +- Auxiliary tasks such as `vision`, `compression`, and `title_generation` are included. An empty task is reported as + `agent`. +- Total tokens are `input + cache_read + cache_write + output`. `reasoning_tokens` is reported separately because it + is a subset of output, not an extra token bucket. +- `auto`, `custom`, empty/unknown, and other ambiguous routes are reported under `unmapped`; a model name alone never + overrides missing resolved billing metadata. +- Hermes rows are cumulative per session/model/route/task. The command reports a current snapshot, **not exact daily + history**. `first_seen` or `last_seen` is not used to assign all cumulative tokens to one day. + +Costs deliberately remain separate: + +- `actualCostUSD`: provider-reported billed cost stored by Hermes. `nil` means unknown, not `$0`. +- `hermesEstimatedCostUSD`: Hermes' stored estimate when its status is `estimated` or `actual`. Subscription-included + rows do not become a fake `$0` estimate. +- `subscriptionIncludedTokens` / `subscriptionIncludedRequests`: usage covered by a subscription route such as + `openai-codex`. +- `apiEquivalentCostUSD`: independent standard-rate estimate from CodexBar's cached models.dev catalog. Subscription + routes use the corresponding direct vendor catalog rather than zero-valued plan pricing. Unknown model pricing stays + unpriced instead of becoming `$0`. + +The Hermes source is intentionally **not auto-added** to provider-native billing totals. OpenAI Admin, Anthropic Admin, +Bedrock Cost Explorer, and similar sources may already include the same API calls; merging them without request-level +identity would double count. Consumers can inspect the standalone Hermes report and choose their own ownership policy. + +## Provider mapping + +The mapping is conservative and keyed by Hermes' persisted canonical `billing_provider`, not by the selected model. +The audit test classifies every canonical Hermes route known on 2026-08-12. + +| Hermes billing provider | CodexBar provider | models.dev rate source | +| --- | --- | --- | +| `openai-codex` | Codex | `openai` | +| `openai-api` | OpenAI | `openai` | +| `anthropic` | Claude | `anthropic` | +| `gemini` | Gemini | `google` | +| `vertex` | Vertex AI | `google-vertex` | +| `openrouter` | OpenRouter | `openrouter` | +| `fireworks` | Fireworks | `fireworks-ai` | +| `bedrock` | AWS Bedrock | `amazon-bedrock` | +| `xai` | xAI | `xai` | +| `xai-oauth` | Grok | `xai` | +| `deepseek` | DeepSeek | `deepseek` | +| `zai` | z.ai | `zai` | +| `alibaba` | Qwen Cloud | `alibaba` | +| `alibaba-coding-plan` | Alibaba | `alibaba` | +| `qwen-oauth` | Qwen Cloud | `alibaba` | +| `kimi-coding` | Kimi; Moonshot when resolved to `api.moonshot.ai` | `moonshotai` | +| `kimi-coding-cn` | Moonshot | `moonshotai-cn` | +| `minimax` | MiniMax | `minimax` | +| `minimax-oauth` | MiniMax | `minimax`; `minimax-cn` for the China host | +| `minimax-cn` | MiniMax | `minimax-cn` | +| `copilot`, `copilot-acp` | Copilot | `github-copilot` | +| `opencode-zen` | OpenCode | `opencode` | +| `opencode-go` | OpenCode Go | `opencode-go` | +| `stepfun` | StepFun | `stepfun-ai`; `stepfun` for the China host | +| `xiaomi` | Xiaomi MiMo | `xiaomi` | +| `kilocode` | Kilo | `kilo` | +| `ollama-cloud` | Ollama | `ollama-cloud` | +| `deepinfra` | DeepInfra | `deepinfra` | + +Intentionally unmapped canonical routes are `actual`, `ai-gateway`, `arcee`, `azure-foundry`, `custom`, `gmi`, +`huggingface`, `lmstudio`, `moa`, `nous`, `novita`, `nvidia`, `tencent-tokenhub`, and `upstage`. They are virtual or +custom routes, or CodexBar has no equivalent first-party provider identity. They remain visible in `unmapped` totals. diff --git a/docs/providers.md b/docs/providers.md index e4d663a99f..e39b0f64a4 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -32,6 +32,10 @@ the scan window; a 30-day selection is not labeled as complete when the availabl The view stays local and does not upload usage history. Refreshes retain the last successful model if a replacement scan fails, while provider/account configuration changes replace obsolete results. +Hermes Agent attribution is available separately through `codexbar hermes-usage`. It reads local WAL-aware SQLite in +read-only mode and does not auto-merge with native provider billing, because those sources can describe the same API +calls. See `docs/hermes-usage.md` for provider mapping and cost semantics. + | Provider | Strategies (ordered for auto) | | --- | --- | | Codex | App Auto: OAuth API (`oauth`) → CLI RPC/PTy (`codex-cli`). CLI Auto: Web dashboard (`openai-web`) → CLI RPC/PTy (`codex-cli`). |