diff --git a/README.md b/README.md index d6261847e3..f384b09741 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Deepgram](docs/deepgram.md) — API key usage summaries across speech, agent, token, and TTS metrics. - [Poe](docs/poe.md) — API key for current point balance and recent points history. - [Chutes](docs/chutes.md) — API key for subscription usage, rolling and monthly quota windows, and pay-as-you-go quotas. +- [Neuralwatt](docs/neuralwatt.md) — API key for USD credit balance and optional per-key spending allowance. - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 532b508f16..766f2f79e1 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1248,7 +1248,7 @@ extension UsageMenuCardView.Model { primaryDetailLeft = detail } if input.provider == .warp || input.provider == .kilo || input.provider == .mimo || input.provider == .deepseek - || input.provider == .litellm, + || input.provider == .neuralwatt || input.provider == .litellm, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1273,7 +1273,7 @@ extension UsageMenuCardView.Model { primaryDetailText = detail if input.provider == .manus { primaryResetText = nil } } - if [.warp, .kilo, .mimo, .deepseek, .litellm].contains(input.provider), primary.resetsAt == nil { + if [.warp, .kilo, .mimo, .deepseek, .neuralwatt, .litellm].contains(input.provider), primary.resetsAt == nil { primaryResetText = nil } // Abacus: show credits as detail, compute pace on the primary monthly window @@ -1339,8 +1339,8 @@ extension UsageMenuCardView.Model { primaryPacePercent = regen.pace.pacePercent primaryPaceOnTop = regen.pace.paceOnTop } - let primaryStatusText = input.provider == .deepseek ? primaryDetailText : nil - if input.provider == .deepseek { + let primaryStatusText = input.provider == .deepseek || input.provider == .neuralwatt ? primaryDetailText : nil + if input.provider == .deepseek || input.provider == .neuralwatt { primaryDetailText = nil } return Metric( diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index f63a8cdd88..12cbc6f808 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -153,7 +153,7 @@ struct MenuDescriptor { if let primary = snap.primary { let primaryDetail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) let primaryDescriptionIsDetail = provider == .warp || provider == .kilo || provider == .abacus || - provider == .deepseek || provider == .azureopenai || provider == .mimo + provider == .deepseek || provider == .neuralwatt || provider == .azureopenai || provider == .mimo let primaryWindow = if primaryDescriptionIsDetail { // Some providers use resetDescription for non-reset detail // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. diff --git a/Sources/CodexBar/Providers/NeuralWatt/NeuralWattProviderImplementation.swift b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattProviderImplementation.swift new file mode 100644 index 0000000000..54847e3f98 --- /dev/null +++ b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattProviderImplementation.swift @@ -0,0 +1,43 @@ +import CodexBarCore +import Foundation + +struct NeuralWattProviderImplementation: ProviderImplementation { + let id: UsageProvider = .neuralwatt + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.neuralWattAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if NeuralWattSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + if !context.settings.neuralWattAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + return !context.settings.tokenAccounts(for: .neuralwatt).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "neuralwatt-api-key", + title: "API key", + subtitle: "Stored in the CodexBar config file. Manage keys from the Neuralwatt dashboard.", + kind: .secure, + placeholder: "sk-...", + binding: context.stringBinding(\.neuralWattAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/NeuralWatt/NeuralWattSettingsStore.swift b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattSettingsStore.swift new file mode 100644 index 0000000000..c5aa7a1625 --- /dev/null +++ b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var neuralWattAPIKey: String { + get { self.configSnapshot.providerConfig(for: .neuralwatt)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .neuralwatt) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .neuralwatt, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index f3b8237451..e7816efa9d 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -67,6 +67,7 @@ enum ProviderImplementationRegistry { case .deepgram: DeepgramProviderImplementation() case .poe: PoeProviderImplementation() case .chutes: ChutesProviderImplementation() + case .neuralwatt: NeuralWattProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg b/Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg new file mode 100644 index 0000000000..cf43777aca --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 9e644794b4..a0f464381d 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -143,7 +143,7 @@ extension SettingsStore { static func isBalanceOnlyProvider(_ provider: UsageProvider) -> Bool { switch provider { - case .deepseek, .mistral, .kimik2, .moonshot, .poe: + case .deepseek, .mistral, .kimik2, .moonshot, .neuralwatt, .poe: true default: false diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 3d84aa2835..5fe4e3c7bc 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -586,6 +586,34 @@ extension UsageStore { return (index, account, descriptor, context) } + if let delay = TokenAccountSupportCatalog.support(for: provider)?.minimumDelayBetweenAccountRefreshes { + var results: [TokenAccountFetchResult] = [] + results.reserveCapacity(requests.count) + for request in requests { + if !results.isEmpty { + do { + try await Task.sleep(for: delay) + } catch { + for pending in requests.dropFirst(results.count) { + results.append(TokenAccountFetchResult( + index: pending.index, + account: pending.account, + outcome: ProviderFetchOutcome( + result: .failure(CancellationError()), + attempts: []))) + } + return results + } + } + let outcome = await request.descriptor.fetchOutcome(context: request.context) + results.append(TokenAccountFetchResult( + index: request.index, + account: request.account, + outcome: outcome)) + } + return results + } + return await withTaskGroup( of: TokenAccountFetchResult.self, returning: [TokenAccountFetchResult].self) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index e02a2443a5..ba9278459f 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1098,7 +1098,8 @@ extension UsageStore { case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, - .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes: + .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, + .neuralwatt: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index c5337d4d5b..b2e6fd4d63 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -237,6 +237,8 @@ extension CodexBarCLI { GroqSettingsReader.apiKey(environment: environment) != nil case .kilo: KiloSettingsReader.apiKey(environment: environment) != nil + case .neuralwatt: + NeuralWattSettingsReader.apiKey(environment: environment) != nil default: false } diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 803a760ecc..7020790304 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -210,7 +210,16 @@ extension CodexBarCLI { let selections = Self.accountSelections(from: accounts) var output = UsageCommandOutput() - for account in selections { + let accountRefreshDelay = TokenAccountSupportCatalog + .support(for: provider)?.minimumDelayBetweenAccountRefreshes + for (index, account) in selections.enumerated() { + if index > 0, let accountRefreshDelay { + do { + try await Task.sleep(for: accountRefreshDelay) + } catch { + return output + } + } let result = await Self.fetchUsageOutput( provider: provider, account: account, diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index f38a560ea8..cf5bdefe5a 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -102,6 +102,7 @@ public enum ProviderConfigEnvironment { } } + // swiftlint:disable:next cyclomatic_complexity private static func directAPIKeyEnvironmentKey(for provider: UsageProvider) -> String? { switch provider { case .amp: @@ -126,6 +127,8 @@ public enum ProviderConfigEnvironment { OpenRouterSettingsReader.envKey case .elevenlabs: ElevenLabsSettingsReader.apiKeyEnvironmentKey + case .neuralwatt: + NeuralWattSettingsReader.apiKeyEnvironmentKey case .moonshot: MoonshotSettingsReader.apiKeyEnvironmentKeys.first case .kimi: diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index c0bc2c1b2b..b82c9ed118 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "2e350d981415198e" + static let value = "752981c35622cb84" } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index f0321295ec..b04d21ddd6 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -55,6 +55,7 @@ public enum LogCategories { public static let minimaxUsage = "minimax-usage" public static let minimaxWeb = "minimax-web" public static let moonshotUsage = "moonshot-usage" + public static let neuralWattUsage = "neuralwatt-usage" public static let notifications = "notifications" public static let openAIWeb = "openai-web" public static let openAIWebview = "openai-webview" diff --git a/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift new file mode 100644 index 0000000000..98e3f0f0ab --- /dev/null +++ b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift @@ -0,0 +1,50 @@ +import Foundation + +public enum NeuralWattProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .neuralwatt, + metadata: ProviderMetadata( + id: .neuralwatt, + displayName: "Neuralwatt", + sessionLabel: "Credits", + weeklyLabel: "Spend", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "Energy-based USD credit balance.", + toggleTitle: "Show Neuralwatt usage", + cliName: "neuralwatt", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://portal.neuralwatt.com/dashboard", + subscriptionDashboardURL: "https://portal.neuralwatt.com/dashboard", + changelogURL: nil, + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .neuralwatt, + iconResourceName: "ProviderIcon-neuralwatt", + color: ProviderColor(red: 0.22, green: 0.85, blue: 0.55)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Neuralwatt token cost history is not available via the quota API." }), + fetchPlan: .apiToken( + strategyID: "neuralwatt.api", + resolveToken: { ProviderTokenResolver.neuralWattToken(environment: $0) }, + missingCredentialsError: { NeuralWattUsageError.missingCredentials }, + loadUsage: { apiKey, context in + try await NeuralWattUsageFetcher.fetchUsage( + apiKey: apiKey, + environment: context.env).toUsageSnapshot() + }), + cli: ProviderCLIConfig( + name: "neuralwatt", + aliases: ["nw", "neural"], + versionDetector: nil)) + } +} diff --git a/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattSettingsReader.swift b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattSettingsReader.swift new file mode 100644 index 0000000000..cea42dab70 --- /dev/null +++ b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattSettingsReader.swift @@ -0,0 +1,61 @@ +import Foundation + +public enum NeuralWattSettingsReader { + public static let apiKeyEnvironmentKey = "NEURALWATT_API_KEY" + public static let apiKeyEnvironmentKeys = [ + Self.apiKeyEnvironmentKey, + ] + public static let apiURLEnvironmentKey = "NEURALWATT_API_URL" + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiKeyEnvironmentKeys { + guard let token = self.cleaned(environment[key]) else { continue } + return token + } + return nil + } + + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = self.validAPIURL(environment: environment) { + return override + } + return URL(string: "https://api.neuralwatt.com")! + } + + public static func validateEndpointOverrides( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard let raw = self.cleaned(environment[self.apiURLEnvironmentKey]) else { return } + guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) == nil else { return } + throw NeuralWattSettingsError.invalidEndpointOverride(self.apiURLEnvironmentKey) + } + + 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()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private static func validAPIURL(environment: [String: String]) -> URL? { + guard let raw = self.cleaned(environment[self.apiURLEnvironmentKey]) else { return nil } + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) + } +} + +public enum NeuralWattSettingsError: LocalizedError, Sendable, Equatable { + case invalidEndpointOverride(String) + + public var errorDescription: String? { + switch self { + case let .invalidEndpointOverride(key): + "Neuralwatt endpoint override \(key) must use HTTPS or a bare host." + } + } +} diff --git a/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattUsageFetcher.swift b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattUsageFetcher.swift new file mode 100644 index 0000000000..33b351c290 --- /dev/null +++ b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattUsageFetcher.swift @@ -0,0 +1,442 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - Response models + +public struct NeuralWattBalance: Codable, Sendable, Equatable { + public let creditsRemainingUSD: Double? + public let totalCreditsUSD: Double? + public let creditsUsedUSD: Double? + public let accountingMethod: String? + + private enum CodingKeys: String, CodingKey { + case creditsRemainingUSD = "credits_remaining_usd" + case totalCreditsUSD = "total_credits_usd" + case creditsUsedUSD = "credits_used_usd" + case accountingMethod = "accounting_method" + } +} + +public struct NeuralWattUsagePeriod: Codable, Sendable, Equatable { + public let costUSD: Double? + public let requests: Int? + public let tokens: Int? + public let energyKWh: Double? + + private enum CodingKeys: String, CodingKey { + case costUSD = "cost_usd" + case requests + case tokens + case energyKWh = "energy_kwh" + } +} + +public struct NeuralWattUsage: Codable, Sendable, Equatable { + public let lifetime: NeuralWattUsagePeriod? + public let currentMonth: NeuralWattUsagePeriod? + + private enum CodingKeys: String, CodingKey { + case lifetime + case currentMonth = "current_month" + } +} + +public struct NeuralWattLimits: Codable, Sendable, Equatable { + public let overageLimitUSD: Double? + public let rateLimitTier: String? + + private enum CodingKeys: String, CodingKey { + case overageLimitUSD = "overage_limit_usd" + case rateLimitTier = "rate_limit_tier" + } +} + +public struct NeuralWattSubscription: Codable, Sendable, Equatable { + public let plan: String? + public let status: String? + public let billingInterval: String? + public let currentPeriodStart: Date? + public let currentPeriodEnd: Date? + public let autoRenew: Bool? + public let kwhIncluded: Double? + public let kwhUsed: Double? + public let kwhRemaining: Double? + public let inOverage: Bool? + + private enum CodingKeys: String, CodingKey { + case plan + case status + case billingInterval = "billing_interval" + case currentPeriodStart = "current_period_start" + case currentPeriodEnd = "current_period_end" + case autoRenew = "auto_renew" + case kwhIncluded = "kwh_included" + case kwhUsed = "kwh_used" + case kwhRemaining = "kwh_remaining" + case inOverage = "in_overage" + } +} + +public struct NeuralWattKeyAllowance: Codable, Sendable, Equatable { + public let limitUSD: Double? + public let period: String? + public let spentUSD: Double? + public let remainingUSD: Double? + public let blocked: Bool? + + private enum CodingKeys: String, CodingKey { + case limitUSD = "limit_usd" + case period + case spentUSD = "spent_usd" + case remainingUSD = "remaining_usd" + case blocked + } +} + +public struct NeuralWattKey: Codable, Sendable, Equatable { + public let name: String? + public let allowance: NeuralWattKeyAllowance? +} + +public struct NeuralWattQuotaResponse: Decodable, Sendable { + public let snapshotAt: String? + public let balance: NeuralWattBalance? + public let usage: NeuralWattUsage? + public let limits: NeuralWattLimits? + public let subscription: NeuralWattSubscription? + public let key: NeuralWattKey? + + private enum CodingKeys: String, CodingKey { + case snapshotAt = "snapshot_at" + case balance + case usage + case limits + case subscription + case key + } + + private init( + snapshotAt: String?, + balance: NeuralWattBalance?, + usage: NeuralWattUsage?, + limits: NeuralWattLimits?, + subscription: NeuralWattSubscription?, + key: NeuralWattKey?) + { + self.snapshotAt = snapshotAt + self.balance = balance + self.usage = usage + self.limits = limits + self.subscription = subscription + self.key = key + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.snapshotAt = try container.decodeIfPresent(String.self, forKey: .snapshotAt) + self.balance = try container.decodeIfPresent(NeuralWattBalance.self, forKey: .balance) + self.usage = try container.decodeIfPresent(NeuralWattUsage.self, forKey: .usage) + self.limits = try container.decodeIfPresent(NeuralWattLimits.self, forKey: .limits) + // `subscription` is documented as always-present: object when active, `null` otherwise. + self.subscription = try container.decodeIfPresent(NeuralWattSubscription.self, forKey: .subscription) + self.key = try container.decodeIfPresent(NeuralWattKey.self, forKey: .key) + } +} + +// MARK: - Snapshot + +public struct NeuralWattUsageSnapshot: Codable, Sendable, Equatable { + public let creditsRemainingUSD: Double? + public let totalCreditsUSD: Double? + public let creditsUsedUSD: Double? + public let accountingMethod: String? + public let currentMonthCostUSD: Double? + public let currentMonthEnergyKWh: Double? + public let subscription: NeuralWattSubscription? + public let keyAllowance: NeuralWattKeyAllowance? + public let rateLimitTier: String? + public let updatedAt: Date + + public init( + creditsRemainingUSD: Double?, + totalCreditsUSD: Double?, + creditsUsedUSD: Double?, + accountingMethod: String?, + currentMonthCostUSD: Double?, + currentMonthEnergyKWh: Double?, + subscription: NeuralWattSubscription?, + keyAllowance: NeuralWattKeyAllowance?, + rateLimitTier: String?, + updatedAt: Date) + { + self.creditsRemainingUSD = creditsRemainingUSD + self.totalCreditsUSD = totalCreditsUSD + self.creditsUsedUSD = creditsUsedUSD + self.accountingMethod = accountingMethod + self.currentMonthCostUSD = currentMonthCostUSD + self.currentMonthEnergyKWh = currentMonthEnergyKWh + self.subscription = subscription + self.keyAllowance = keyAllowance + self.rateLimitTier = rateLimitTier + self.updatedAt = updatedAt + } + + public var creditUsedPercent: Double { + if self.hasKnownZeroRemainingBalance { + return 100 + } + guard let used = self.effectiveUsedCredits, let total = self.effectiveTotalCredits, total > 0 else { + return 0 + } + return min(100, max(0, used / total * 100)) + } + + private var hasKnownZeroRemainingBalance: Bool { + Self.validNonNegative(self.creditsRemainingUSD) == 0 && self.effectiveTotalCredits == nil + } + + public var effectiveRemainingCredits: Double? { + if let remaining = Self.validNonNegative(self.creditsRemainingUSD) { return remaining } + guard let total = self.effectiveTotalCredits, let used = self.effectiveUsedCredits else { return nil } + return max(0, total - used) + } + + public var effectiveTotalCredits: Double? { + if let total = Self.validPositive(self.totalCreditsUSD) { return total } + guard let remaining = Self.validNonNegative(self.creditsRemainingUSD), + let used = Self.validNonNegative(self.creditsUsedUSD) + else { return nil } + let total = remaining + used + return total > 0 ? total : nil + } + + public var effectiveUsedCredits: Double? { + if let used = Self.validNonNegative(self.creditsUsedUSD) { return used } + guard let total = Self.validPositive(self.totalCreditsUSD), + let remaining = Self.validNonNegative(self.creditsRemainingUSD) + else { return nil } + return max(0, total - remaining) + } + + public var keyAllowanceUsedPercent: Double? { + guard let spent = self.keyAllowance?.spentUSD, let limit = self.keyAllowance?.limitUSD, limit > 0 else { + return nil + } + return min(100, max(0, spent / limit * 100)) + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Neuralwatt is a credit-exhaustion model (like DeepSeek): USD credits deplete + // as you use the API and do not reset on a billing cycle. There is no renewal + // date to surface, so the primary window carries only the balance summary. + let primary = RateWindow( + usedPercent: self.creditUsedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: self.creditSummary) + + var extras: [NamedRateWindow] = [] + if let percent = self.keyAllowanceUsedPercent, let allowance = self.keyAllowance { + let periodTitle = (allowance.period ?? "allowance").capitalized + extras.append(NamedRateWindow( + id: "key-allowance", + title: "Key \(periodTitle)", + window: RateWindow( + usedPercent: percent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil))) + } + + let identity = ProviderIdentitySnapshot( + providerID: .neuralwatt, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.displayLoginMethod) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + extraRateWindows: extras.isEmpty ? nil : extras, + subscriptionRenewsAt: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + private var creditSummary: String { + guard let remaining = self.effectiveRemainingCredits else { return "Balance unavailable" } + guard let total = self.effectiveTotalCredits else { + return "\(Self.formatUSD(remaining)) remaining" + } + return "\(Self.formatUSD(remaining)) remaining of \(Self.formatUSD(total))" + } + + private var displayLoginMethod: String? { + // Credits are account-wide; surface the accounting method (Token vs Energy) + // when present. Subscription plan is shown only as supplementary identity. + if let method = self.accountingMethod, !method.isEmpty { + return method.capitalized + } + return self.subscription?.plan?.replacingOccurrences(of: "_", with: " ").capitalized + } + + fileprivate static func validNonNegative(_ value: Double?) -> Double? { + guard let value, value.isFinite, value >= 0 else { return nil } + return value + } + + fileprivate static func validPositive(_ value: Double?) -> Double? { + guard let value, value.isFinite, value > 0 else { return nil } + return value + } + + private static func formatUSD(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 2 + return formatter.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value) + } +} + +// MARK: - Errors + +public enum NeuralWattUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Neuralwatt API key. Set apiKey in the CodexBar config file or NEURALWATT_API_KEY." + case let .networkError(message): + "Neuralwatt network error: \(message)" + case let .apiError(message): + "Neuralwatt API error: \(message)" + case let .parseFailed(message): + "Failed to parse Neuralwatt response: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct NeuralWattUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.neuralWattUsage) + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> NeuralWattUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw NeuralWattUsageError.missingCredentials + } + try NeuralWattSettingsReader.validateEndpointOverrides(environment: environment) + + let url = Self.quotaURL(baseURL: NeuralWattSettingsReader.apiURL(environment: environment)) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + throw NeuralWattUsageError.networkError(error.localizedDescription) + } + + switch response.statusCode { + case 200: + return try Self.parseSnapshot(data: response.data, updatedAt: Date()) + case 401, 403: + throw NeuralWattUsageError.missingCredentials + default: + Self.log.error("Neuralwatt API returned \(response.statusCode)") + throw NeuralWattUsageError.apiError("HTTP \(response.statusCode)") + } + } + + static func _parseSnapshotForTesting(_ data: Data, updatedAt: Date) throws -> NeuralWattUsageSnapshot { + try self.parseSnapshot(data: data, updatedAt: updatedAt) + } + + private static func parseSnapshot(data: Data, updatedAt: Date) throws -> NeuralWattUsageSnapshot { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom(Self.decodeISO8601Date) + let decoded: NeuralWattQuotaResponse + do { + decoded = try decoder.decode(NeuralWattQuotaResponse.self, from: data) + } catch { + throw NeuralWattUsageError.parseFailed(error.localizedDescription) + } + + guard let balance = decoded.balance else { + throw NeuralWattUsageError.parseFailed("Missing Neuralwatt balance object") + } + guard NeuralWattUsageSnapshot.validNonNegative(balance.creditsRemainingUSD) != nil || + NeuralWattUsageSnapshot.validNonNegative(balance.creditsUsedUSD) != nil || + NeuralWattUsageSnapshot.validPositive(balance.totalCreditsUSD) != nil + else { + throw NeuralWattUsageError.parseFailed("Missing Neuralwatt credit balance fields") + } + + return NeuralWattUsageSnapshot( + creditsRemainingUSD: balance.creditsRemainingUSD, + totalCreditsUSD: balance.totalCreditsUSD, + creditsUsedUSD: balance.creditsUsedUSD, + accountingMethod: balance.accountingMethod, + currentMonthCostUSD: decoded.usage?.currentMonth?.costUSD, + currentMonthEnergyKWh: decoded.usage?.currentMonth?.energyKWh, + subscription: decoded.subscription, + keyAllowance: decoded.key?.allowance, + rateLimitTier: decoded.limits?.rateLimitTier, + updatedAt: updatedAt) + } + + private static func decodeISO8601Date(from decoder: Decoder) throws -> Date { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + let standardFormatter = ISO8601DateFormatter() + standardFormatter.formatOptions = [.withInternetDateTime] + if let date = standardFormatter.date(from: value) { + return date + } + + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractionalFormatter.date(from: value) { + return date + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO8601 date: \(value)") + } + + private static func quotaURL(baseURL: URL) -> URL { + var url = baseURL + let pathComponents = url.path.split(separator: "/") + if pathComponents.last == "v1" { + url.append(path: "quota") + } else { + url.append(path: "v1/quota") + } + return url + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index fd38727e18..fb3cf1c5ea 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -107,6 +107,7 @@ public enum ProviderDescriptorRegistry { .deepgram: DeepgramProviderDescriptor.descriptor, .poe: PoeProviderDescriptor.descriptor, .chutes: ChutesProviderDescriptor.descriptor, + .neuralwatt: NeuralWattProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 3bf7117fa7..947c0cb620 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -105,6 +105,12 @@ public enum ProviderTokenResolver { self.elevenLabsResolution(environment: environment)?.token } + public static func neuralWattToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.neuralWattResolution(environment: environment)?.token + } + public static func groqToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.groqResolution(environment: environment)?.token } @@ -346,6 +352,12 @@ public enum ProviderTokenResolver { self.resolveEnv(ElevenLabsSettingsReader.apiKey(environment: environment)) } + public static func neuralWattResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(NeuralWattSettingsReader.apiKey(environment: environment)) + } + public static func groqResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 4fc5b4357a..1192f65ff2 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -57,6 +57,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case deepgram case poe case chutes + case neuralwatt } // swiftformat:enable sortDeclarations @@ -114,6 +115,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case deepgram case poe case chutes + case neuralwatt case combined } diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index 37ec660a97..eeca07e208 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -13,6 +13,7 @@ public struct TokenAccountSupport: Sendable { public let requiresManualCookieSource: Bool public let cookieName: String? public let environmentKeysToScrub: [String] + public let minimumDelayBetweenAccountRefreshes: Duration? public init( title: String, @@ -21,7 +22,8 @@ public struct TokenAccountSupport: Sendable { injection: TokenAccountInjection, requiresManualCookieSource: Bool, cookieName: String?, - environmentKeysToScrub: [String] = []) + environmentKeysToScrub: [String] = [], + minimumDelayBetweenAccountRefreshes: Duration? = nil) { self.title = title self.subtitle = subtitle @@ -30,6 +32,7 @@ public struct TokenAccountSupport: Sendable { self.requiresManualCookieSource = requiresManualCookieSource self.cookieName = cookieName self.environmentKeysToScrub = environmentKeysToScrub + self.minimumDelayBetweenAccountRefreshes = minimumDelayBetweenAccountRefreshes } } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 4db3a50bca..5d55c9a42a 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -129,6 +129,14 @@ extension TokenAccountSupportCatalog { injection: .environment(key: ElevenLabsSettingsReader.apiKeyEnvironmentKey), requiresManualCookieSource: false, cookieName: nil), + .neuralwatt: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple Neuralwatt API keys.", + placeholder: "sk-...", + injection: .environment(key: NeuralWattSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil, + minimumDelayBetweenAccountRefreshes: .seconds(1)), .groq: TokenAccountSupport( title: "API keys", subtitle: "Store multiple Groq API keys.", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index e739dad4d7..a2c6a46446 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -464,7 +464,7 @@ enum CostUsageScanner { .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .stepfun, - .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes: + .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .neuralwatt: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 4e35d422f1..b6c4a68fa6 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -108,6 +108,7 @@ enum ProviderChoice: String, AppEnum { case .poe: return nil // Poe not yet supported in widgets case .chutes: return nil // Chutes not yet supported in widgets case .zed: return nil // Zed not yet supported in widgets + case .neuralwatt: return nil // Neuralwatt not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 640e4c0e47..58f54aa08d 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -323,6 +323,7 @@ private struct ProviderSwitchChip: View { case .poe: "Poe" case .chutes: "Chutes" case .zed: "Zed" + case .neuralwatt: "Neuralwatt" } } } @@ -878,6 +879,8 @@ enum WidgetColors { Color(red: 24 / 255, green: 160 / 255, blue: 88 / 255) case .zed: Color(red: 64 / 255, green: 156 / 255, blue: 255 / 255) + case .neuralwatt: + Color(red: 56 / 255, green: 217 / 255, blue: 140 / 255) } } } diff --git a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift index 68175d630a..fd10e40f28 100644 --- a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift +++ b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift @@ -115,6 +115,19 @@ struct CLIDiagnoseCommandTests { #expect(summary.modes == ["api"]) } + @Test + func `generic diagnose auth summary detects Neuralwatt environment credentials`() { + let summary = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .neuralwatt, + account: nil, + config: nil, + environment: [NeuralWattSettingsReader.apiKeyEnvironmentKey: "sk-test"], + settings: nil) + + #expect(summary.configured) + #expect(summary.modes == ["api"]) + } + @Test func `generic diagnose auth summary requires complete Bedrock credentials`() { let partial = CodexBarCLI._diagnosticAuthSummaryForTesting( diff --git a/Tests/CodexBarTests/MenuCardNeuralWattTests.swift b/Tests/CodexBarTests/MenuCardNeuralWattTests.swift new file mode 100644 index 0000000000..99b0ce901f --- /dev/null +++ b/Tests/CodexBarTests/MenuCardNeuralWattTests.swift @@ -0,0 +1,56 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardNeuralWattTests { + @Test + func `model shows credit balance as status text without reset wording`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = NeuralWattUsageSnapshot( + creditsRemainingUSD: 51.00, + totalCreditsUSD: 77.04, + creditsUsedUSD: 26.04, + accountingMethod: "energy", + currentMonthCostUSD: 12.34, + currentMonthEnergyKWh: 0.25, + subscription: nil, + keyAllowance: nil, + rateLimitTier: "standard", + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.neuralwatt]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .neuralwatt, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.title == "Credits") + let statusText = primary.statusText?.replacingOccurrences(of: "\u{00A0}", with: "") + #expect(statusText == "$51.00 remaining of $77.04") + #expect(primary.detailText == nil) + #expect(primary.resetText == nil) + #expect(model.creditsText == nil) + #expect(model.metrics.contains { $0.title == "This month" } == false) + #expect(model.metrics.allSatisfy { metric in + metric.resetText?.localizedCaseInsensitiveContains("reset") != true + }) + } +} diff --git a/Tests/CodexBarTests/NeuralWattUsageFetcherTests.swift b/Tests/CodexBarTests/NeuralWattUsageFetcherTests.swift new file mode 100644 index 0000000000..e4ac56859a --- /dev/null +++ b/Tests/CodexBarTests/NeuralWattUsageFetcherTests.swift @@ -0,0 +1,409 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct NeuralWattUsageFetcherTests { + @Test + func `parses quota response into usage snapshot`() throws { + let body = #""" + { + "snapshot_at": "2026-04-16T18:30:00Z", + "balance": { + "credits_remaining_usd": 32.6774, + "total_credits_usd": 52.34, + "credits_used_usd": 19.6626, + "accounting_method": "energy" + }, + "usage": { + "lifetime": { + "cost_usd": 243.9145, + "requests": 37801, + "tokens": 1235477176, + "energy_kwh": 15.6009 + }, + "current_month": { + "cost_usd": 160.1463, + "requests": 23902, + "tokens": 1116658995, + "energy_kwh": 9.7278 + } + }, + "limits": { + "overage_limit_usd": null, + "rate_limit_tier": "standard" + }, + "subscription": { + "plan": "standard", + "status": "active", + "billing_interval": "month", + "current_period_start": "2026-04-11T05:05:25Z", + "current_period_end": "2026-05-11T05:05:25Z", + "auto_renew": true, + "kwh_included": 20.0, + "kwh_used": 13.9023, + "kwh_remaining": 6.0977, + "in_overage": false + }, + "key": { + "name": "my-production-key", + "allowance": { + "limit_usd": 50.0, + "period": "monthly", + "spent_usd": 12.5, + "remaining_usd": 37.5, + "blocked": false + } + } + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.totalCreditsUSD == 52.34) + #expect(snapshot.creditsUsedUSD == 19.6626) + let expectedCreditPercent = 19.6626 / 52.34 * 100 + #expect(abs(snapshot.creditUsedPercent - expectedCreditPercent) < 1e-6) + // Credits exhaust (DeepSeek-style); no kWh window surfaced. + #expect(snapshot.keyAllowanceUsedPercent == 25.0) + #expect(snapshot.currentMonthCostUSD == 160.1463) + let primaryPercent = usage.primary?.usedPercent + #expect(primaryPercent.map { abs($0 - expectedCreditPercent) < 1e-6 } == true) + // No reset cycle — credits deplete, they don't renew. + #expect(usage.primary?.resetsAt == nil) + #expect(usage.subscriptionRenewsAt == nil) + // loginMethod now reflects accounting method when present. + #expect(usage.loginMethod(for: .neuralwatt) == "Energy") + // Only key allowance is surfaced as an extra quota window. Current-month spend is telemetry, + // not a resettable rate window. + #expect(usage.extraRateWindows?.count == 1) + #expect(usage.extraRateWindows?.contains { $0.id == "subscription-kwh" } == false) + #expect(usage.extraRateWindows?.contains { $0.id == "current-month-spend" } == false) + let allowanceWindow = usage.extraRateWindows?.first { $0.id == "key-allowance" } + #expect(allowanceWindow?.title == "Key Monthly") + } + + @Test + func `parses response with null subscription using accounting method`() throws { + let body = #""" + { + "snapshot_at": "2026-04-16T18:30:00Z", + "balance": { + "credits_remaining_usd": 4.5, + "total_credits_usd": 5.0, + "credits_used_usd": 0.5, + "accounting_method": "energy" + }, + "usage": { + "lifetime": {"cost_usd": 0.5, "requests": 10, "tokens": 1000, "energy_kwh": 0.01}, + "current_month": {"cost_usd": 0.5, "requests": 10, "tokens": 1000, "energy_kwh": 0.01} + }, + "limits": {"overage_limit_usd": null, "rate_limit_tier": "free"}, + "subscription": null, + "key": {"name": "trial", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 100)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.creditUsedPercent == 10) + #expect(snapshot.subscription == nil) + #expect(snapshot.keyAllowanceUsedPercent == nil) + #expect(usage.primary?.usedPercent == 10) + #expect(usage.subscriptionRenewsAt == nil) + #expect(usage.loginMethod(for: .neuralwatt) == "Energy") + // No resettable extra quota windows when there is no per-key allowance. + #expect(usage.extraRateWindows == nil) + } + + @Test + func `parses response with missing credits used derived from remaining`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 30.0, + "total_credits_usd": 100.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": null, + "key": {"name": "x", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 2)) + // credits_used_usd missing but derived as 100 - 30 = 70. + #expect(snapshot.effectiveUsedCredits == 70) + #expect(snapshot.creditUsedPercent == 70) + } + + @Test + func `marks known zero credit balance as exhausted`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 0.0, + "total_credits_usd": 0.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": null, + "key": {"name": "x", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 2)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.effectiveRemainingCredits == 0) + #expect(snapshot.effectiveTotalCredits == nil) + #expect(snapshot.creditUsedPercent == 100) + #expect(usage.primary?.usedPercent == 100) + let resetDescription = usage.primary?.resetDescription?.replacingOccurrences(of: "\u{00A0}", with: "") + #expect(resetDescription == "$0.00 remaining") + } + + @Test + func `parses fractional subscription dates`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 8.0, + "total_credits_usd": 10.0, + "credits_used_usd": 2.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": { + "plan": "standard", + "status": "active", + "current_period_start": "2026-04-11T05:05:25.123Z", + "current_period_end": "2026-05-11T05:05:25.456Z" + }, + "key": {"name": "x", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 3)) + + #expect(snapshot.subscription?.currentPeriodEnd != nil) + #expect(snapshot.creditUsedPercent == 20) + } + + @Test + func `rejects malformed successful response without balance`() throws { + let body = #"{"error":"temporarily unavailable"}"# + + do { + _ = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 4)) + Issue.record("Expected NeuralWattUsageError.parseFailed") + } catch let error as NeuralWattUsageError { + guard case let .parseFailed(message) = error else { + Issue.record("Expected parseFailed, got \(error)") + return + } + #expect(message.contains("balance")) + } + } + + @Test + func `fetch usage rejects blank API key before request`() async throws { + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: " ", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + Issue.record("Expected NeuralWattUsageError.missingCredentials") + } catch let error as NeuralWattUsageError { + guard case .missingCredentials = error else { + Issue.record("Expected missingCredentials, got \(error)") + return + } + } + } + + @Test + func `fetch rejects endpoint override before sending API key`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + Issue.record("Endpoint override validation must happen before the request") + throw URLError(.badURL) + } + + await #expect(throws: NeuralWattSettingsError.invalidEndpointOverride( + NeuralWattSettingsReader.apiURLEnvironmentKey)) + { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://user@example.com"], + transport: transport) + } + } + + @Test + func `fetch preserves transport cancellation`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + throw CancellationError() + } + + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [:], + transport: transport) + Issue.record("Expected CancellationError") + } catch is CancellationError { + // Expected: refresh cancellation must not become a provider error. + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + } + + @Test + func `unauthorized fetch throws missing credentials`() async throws { + let registered = URLProtocol.registerClass(NeuralWattStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(NeuralWattStubURLProtocol.self) + } + NeuralWattStubURLProtocol.handler = nil + } + + NeuralWattStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: #"{"detail":"bad key"}"#, statusCode: 401) + } + + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + Issue.record("Expected NeuralWattUsageError.missingCredentials") + } catch let error as NeuralWattUsageError { + guard case .missingCredentials = error else { + Issue.record("Expected missingCredentials, got \(error)") + return + } + } + } + + @Test + func `fetch usage sends bearer authorization header`() async throws { + let registered = URLProtocol.registerClass(NeuralWattStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(NeuralWattStubURLProtocol.self) + } + NeuralWattStubURLProtocol.handler = nil + } + + NeuralWattStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.path == "/v1/quota") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-test") + #expect(request.timeoutInterval == 15) + + let body = #""" + { + "balance": {"credits_remaining_usd": 5.0, "total_credits_usd": 10.0, + "credits_used_usd": 5.0, "accounting_method": "energy"}, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, "subscription": null, "key": {"name": "k", "allowance": null} + } + """# + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let usage = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: " sk-test ", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + + #expect(usage.creditUsedPercent == 50) + } + + @Test + func `non success fetch throws generic HTTP error`() async throws { + let registered = URLProtocol.registerClass(NeuralWattStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(NeuralWattStubURLProtocol.self) + } + NeuralWattStubURLProtocol.handler = nil + } + + NeuralWattStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: #"{"detail":"bad key"}"#, statusCode: 500) + } + + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + Issue.record("Expected NeuralWattUsageError.apiError") + } catch let error as NeuralWattUsageError { + guard case let .apiError(message) = error else { + Issue.record("Expected apiError, got \(error)") + return + } + #expect(message == "HTTP 500") + } + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +final class NeuralWattStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "api.neuralwatt.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 84a139c5c9..73e9d8748d 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -123,6 +123,19 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.elevenLabsToken(environment: env) == "xi-token") } + @Test + func `applies API key override for NeuralWatt`() { + let config = ProviderConfig(id: .neuralwatt, apiKey: "sk-neuralwatt-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .neuralwatt, + config: config) + + #expect(env[NeuralWattSettingsReader.apiKeyEnvironmentKey] == "sk-neuralwatt-config") + #expect(ProviderTokenResolver.neuralWattToken(environment: env) == "sk-neuralwatt-config") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .neuralwatt)) + } + @Test func `applies API key override for groq`() { let config = ProviderConfig(id: .groq, apiKey: "gsk-token") diff --git a/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift b/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift index 52bb2fd5cd..604257aea4 100644 --- a/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift +++ b/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift @@ -15,6 +15,18 @@ struct ProviderEnvironmentResolverTests { #expect(environment[ZaiSettingsReader.apiTokenKey] == "account-token") } + @Test + func `NeuralWatt selected API account overrides saved and ambient credentials`() { + let account = Self.account(token: "sk-neuralwatt-account") + let environment = ProviderEnvironmentResolver.resolve( + base: [NeuralWattSettingsReader.apiKeyEnvironmentKey: "ambient-token"], + provider: .neuralwatt, + config: ProviderConfig(id: .neuralwatt, apiKey: "saved-token"), + selectedAccount: account) + + #expect(environment[NeuralWattSettingsReader.apiKeyEnvironmentKey] == "sk-neuralwatt-account") + } + @Test func `OpenAI account removes project scoping from saved config`() { let account = Self.account(token: "sk-admin-account") diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index 97c219b472..a216943eee 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -225,6 +225,20 @@ struct ProvidersPaneCoverageTests { } } + @Test + func `Neuralwatt menu bar metric picker omits nonexistent spend lane`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-neuralwatt-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .neuralwatt) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + } + } + @Test func `moonshot menu bar metric picker shows balance only copy`() { Self.withEnglishLocalization { diff --git a/Tests/CodexBarTests/UsageStoreNeuralWattAccountRefreshTests.swift b/Tests/CodexBarTests/UsageStoreNeuralWattAccountRefreshTests.swift new file mode 100644 index 0000000000..143ae6fab0 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreNeuralWattAccountRefreshTests.swift @@ -0,0 +1,145 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +private actor NeuralWattAccountRefreshRecorder { + private(set) var dates: [Date] = [] + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func record() { + self.dates.append(Date()) + let ready = self.waiters.filter { self.dates.count >= $0.count } + self.waiters.removeAll { self.dates.count >= $0.count } + ready.forEach { $0.continuation.resume() } + } + + func waitForCount(_ count: Int) async { + if self.dates.count >= count { return } + await withCheckedContinuation { continuation in + self.waiters.append((count, continuation)) + } + } +} + +private struct NeuralWattAccountRefreshStrategy: ProviderFetchStrategy { + let recorder: NeuralWattAccountRefreshRecorder + + let id = "neuralwatt-account-refresh-test" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + await self.recorder.record() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + return self.makeResult(usage: snapshot, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +@MainActor +@Suite(.serialized) +struct UsageStoreNeuralWattAccountRefreshTests { + @Test + func `multi-account refresh respects Neuralwatt quota rate limit`() async throws { + let recorder = NeuralWattAccountRefreshRecorder() + let store = try Self.makeStore(recorder: recorder) + let accounts = [ + ProviderTokenAccount(id: UUID(), label: "First", token: "sk-first", addedAt: 0, lastUsed: nil), + ProviderTokenAccount(id: UUID(), label: "Second", token: "sk-second", addedAt: 0, lastUsed: nil), + ] + + await store.refreshTokenAccounts(provider: .neuralwatt, accounts: accounts) + + let dates = await recorder.dates + #expect(dates.count == 2) + #expect(dates[1].timeIntervalSince(dates[0]) >= 0.95) + } + + @Test + func `cancelled delayed refresh preserves every prior account snapshot`() async throws { + let recorder = NeuralWattAccountRefreshRecorder() + let store = try Self.makeStore(recorder: recorder) + let accounts = (0..<3).map { index in + ProviderTokenAccount( + id: UUID(), + label: "Account \(index)", + token: "sk-\(index)", + addedAt: 0, + lastUsed: nil) + } + store.accountSnapshots[.neuralwatt] = accounts.map { account in + TokenAccountUsageSnapshot( + account: account, + snapshot: Self.snapshot(), + error: nil, + sourceLabel: "prior") + } + + let task = Task { @MainActor in + await store.refreshTokenAccounts(provider: .neuralwatt, accounts: accounts) + } + await recorder.waitForCount(1) + task.cancel() + await task.value + + #expect(store.accountSnapshots[.neuralwatt]?.map(\.account.id) == accounts.map(\.id)) + #expect(store.accountSnapshots[.neuralwatt]?.allSatisfy { $0.snapshot != nil } == true) + } + + private static func makeStore(recorder: NeuralWattAccountRefreshRecorder) throws -> UsageStore { + let settings = testSettingsStore( + suiteName: "UsageStoreNeuralWattAccountRefreshTests-\(UUID().uuidString)", + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let baseSpec = try #require(store.providerSpecs[.neuralwatt]) + let baseDescriptor = baseSpec.descriptor + let strategy = NeuralWattAccountRefreshStrategy(recorder: recorder) + store.providerSpecs[.neuralwatt] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .neuralwatt, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + private static func snapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index d787412a1f..f1be658f47 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -169,7 +169,7 @@ z.ai team accounts also use `usageScope`, `organizationId`, and `workspaceID`; s ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`. +`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/neuralwatt.md b/docs/neuralwatt.md new file mode 100644 index 0000000000..6facf998a8 --- /dev/null +++ b/docs/neuralwatt.md @@ -0,0 +1,79 @@ +--- +summary: "Neuralwatt provider notes: API key setup and quota usage fields." +read_when: + - Adding or modifying the Neuralwatt provider + - Debugging Neuralwatt API keys or quota parsing + - Adjusting Neuralwatt credit labels +--- + +# Neuralwatt Provider + +The Neuralwatt provider reads account quota from the Neuralwatt Cloud API using an API key. +Neuralwatt Cloud is an OpenAI-compatible inference API with energy-based pricing. Prepaid credits +are a deplete-as-you-go USD balance: they do **not** reset on a billing cycle and are refilled by +topping up. Active subscription usage is billed separately against a kWh allowance. The quota +endpoint exposes both surfaces plus current-month spend and optional per-key spending allowances. + +## Features + +- USD credit balance as the primary usage window (`credits_remaining_usd` / `total_credits_usd`). + The bar fills as credits are consumed; there is **no reset date** since credits deplete, not renew. +- Per-key spending allowance (`spent_usd` / `limit_usd`) as an extra rate window when configured. +- Accounting method (`Token` vs `Energy`) shown as the provider identity label. +- Current calendar-month spend is parsed for future/reporting use, but is not shown as a resettable quota window. + +## Setup + +### CLI + +Store the API key without opening Settings: + +```bash +printf '%s' "$NEURALWATT_API_KEY" | codexbar config set-api-key --provider neuralwatt --stdin +``` + +This trims the piped key, writes it to CodexBar's config file (`~/.config/codexbar/config.json` +by default, or the legacy `~/.codexbar/config.json` when already present), and enables Neuralwatt by +default. Use `--no-enable` to save the key without enabling the provider. + +### Settings + +1. Open **Settings → Providers** +2. Enable **Neuralwatt** +3. Open `https://portal.neuralwatt.com/dashboard` +4. Create or copy an API key +5. Paste the key into CodexBar's Neuralwatt provider settings + +### Environment Variables + +CodexBar also accepts these environment variables: + +- `NEURALWATT_API_KEY` + +For tests or self-hosted/proxy setups, override the API base URL with `NEURALWATT_API_URL`. + +## How It Works + +- Endpoint: `GET https://api.neuralwatt.com/v1/quota` +- Auth header: `Authorization: Bearer sk-...` +- Fields used: `balance.credits_remaining_usd`, `balance.total_credits_usd`, + `balance.credits_used_usd`, `balance.accounting_method`, + `usage.current_month.cost_usd`, + `key.allowance.limit_usd`, `key.allowance.spent_usd`, `key.allowance.period` +- `credits_used_usd` is derived as `total_credits_usd − credits_remaining_usd` when the API omits it. +- `subscription` may be `null`. Its separate kWh allowance is not rendered yet; landing that display + requires an explicit product decision because it changes the provider from balance-only to mixed quota semantics. + +## Troubleshooting + +### "Missing Neuralwatt API key" + +Set the key with `codexbar config set-api-key --provider neuralwatt --stdin`, add it in +**Settings → Providers → Neuralwatt**, set `NEURALWATT_API_KEY`, or configure a Neuralwatt token +account in CodexBar. + +### "Neuralwatt API error" + +Confirm the API key is valid and that the current network can reach `api.neuralwatt.com`. The +quota endpoint is rate-limited to 1 request per second per customer; CodexBar refreshes on its +normal cycle so this should not be hit in practice. diff --git a/docs/providers.md b/docs/providers.md index 2e6788fc13..c3582a24f3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -73,6 +73,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | LiteLLM | API key + base URL → `/key/info`, then `/user/info` or `/team/info` budget usage (`api`). | | Deepgram | API key → project discovery and usage breakdown API (`api`). | | Chutes | API key from config/env → subscription usage and quota API (`api`). | +| Neuralwatt | API key from config/env → `/v1/quota` credit balance and per-key allowance (`api`). | | Zed | Zed editor Keychain session → `cloud.zed.dev/client/users/me` for plan and quota data (`local`). | ## Codex @@ -431,6 +432,13 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Uses Chutes' management API at `https://api.chutes.ai`; `CHUTES_API_URL` can override it with an HTTPS endpoint. - Details: `docs/chutes.md`. +## Neuralwatt +- API key from config or `NEURALWATT_API_KEY`. +- Reads `GET /v1/quota` from `api.neuralwatt.com`; `NEURALWATT_API_URL` can override it with an HTTPS endpoint. +- Shows the USD prepaid-credit balance and an optional per-key spending allowance. +- Active subscription kWh allowance is returned separately by Neuralwatt and is not yet surfaced pending a product decision. +- Details: `docs/neuralwatt.md`. + ## StepFun - Username/password login or manual Oasis-Token. - Reads Step Plan 5-hour and weekly rate-limit windows from `platform.stepfun.com`.