diff --git a/README.md b/README.md
index 3ef5206fd9..a244c605b4 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
[](LICENSE)
[](https://codexbar.app)
-
+
Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, ClinePass, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons.
@@ -117,6 +117,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow.
- [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking.
- [Mistral](docs/mistral.md) — Browser cookies for API spend, credit balance, and monthly-plan usage.
- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown).
+- [Charm Hyper](docs/hyper.md) — Signed-in session or API key for remaining Hypercredit balance.
- [DeepInfra](docs/deepinfra.md) — API key for prepaid balance, current-month spend, and spending-limit tracking.
- [Moonshot / Kimi API](docs/moonshot.md) — API key for Moonshot/Kimi API account balance tracking.
- [Venice](docs/venice.md) — API key for DIEM or USD balance tracking.
diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift
index 33921e7856..894e1228dd 100644
--- a/Sources/CodexBar/MenuCardView+Costs.swift
+++ b/Sources/CodexBar/MenuCardView+Costs.swift
@@ -127,7 +127,9 @@ extension UsageMenuCardView.Model {
preferredCurrencyCode: String = "auto") -> String?
{
guard metadata.supportsCredits else { return nil }
- if metadata.id == .codex, credits == nil, error == nil { return nil }
+ if metadata.id == .codex, credits == nil, error == nil {
+ return nil
+ }
if metadata.id == .amp,
let ampUsage = snapshot?.ampUsage,
let ampCredits = self.ampCreditsLine(ampUsage, preferredCurrencyCode: preferredCurrencyCode)
@@ -338,13 +340,19 @@ extension UsageMenuCardView.Model {
return (entry, dayKey)
}
.max { lhs, rhs in
- if lhs.dayKey != rhs.dayKey { return lhs.dayKey < rhs.dayKey }
+ if lhs.dayKey != rhs.dayKey {
+ return lhs.dayKey < rhs.dayKey
+ }
let lCost = lhs.entry.costUSD ?? -1
let rCost = rhs.entry.costUSD ?? -1
- if lCost != rCost { return lCost < rCost }
+ if lCost != rCost {
+ return lCost < rCost
+ }
let lTokens = lhs.entry.totalTokens ?? -1
let rTokens = rhs.entry.totalTokens ?? -1
- if lTokens != rTokens { return lTokens < rTokens }
+ if lTokens != rTokens {
+ return lTokens < rTokens
+ }
return lhs.entry.date < rhs.entry.date
}?.entry
}
@@ -396,8 +404,12 @@ extension UsageMenuCardView.Model {
private static func daysInBedrockBillingMonth(_ month: Int, year: Int) -> Int {
switch month {
case 2:
- if year.isMultiple(of: 400) { return 29 }
- if year.isMultiple(of: 100) { return 28 }
+ if year.isMultiple(of: 400) {
+ return 29
+ }
+ if year.isMultiple(of: 100) {
+ return 28
+ }
return year.isMultiple(of: 4) ? 29 : 28
case 4, 6, 9, 11:
return 30
@@ -406,6 +418,54 @@ extension UsageMenuCardView.Model {
}
}
+ /// Providers whose cost snapshot is a plain prepaid balance with no limit or period to chart.
+ private static func balanceOnlyCostSection(
+ provider: UsageProvider,
+ cost: ProviderCostSnapshot,
+ formatCost: (Double) -> String) -> ProviderCostSection?
+ {
+ func balanceSection(title: String, balance: String) -> ProviderCostSection {
+ ProviderCostSection(
+ title: title,
+ percentUsed: nil,
+ spendLine: "\(L("Balance")): \(balance)",
+ percentLine: nil)
+ }
+
+ if provider == .factory || provider == .devin, cost.period == "Extra usage balance" {
+ return balanceSection(title: L("Extra usage"), balance: formatCost(cost.used))
+ }
+
+ if provider == .opencodego, cost.period == "Zen balance" {
+ return balanceSection(title: L("Zen balance"), balance: formatCost(cost.used))
+ }
+
+ if provider == .minimax, cost.period == "MiniMax points balance" {
+ return balanceSection(title: L("Credits"), balance: String(format: "%.0f", cost.used))
+ }
+
+ if provider == .hyper,
+ cost.period == "Hypercredits balance",
+ let value = cost.balance
+ {
+ let balance = value.rounded() == value
+ ? String(format: "%.0f", value)
+ : String(format: "%.2f", value)
+ return balanceSection(title: "Hypercredits", balance: "\(balance) HC")
+ }
+
+ if provider == .xai, cost.period == "Prepaid credits" {
+ let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
+ return balanceSection(title: L("Credits"), balance: balance)
+ }
+
+ if provider == .zenmux || provider == .neuralwatt {
+ return balanceSection(title: L("metric_mistral_payg"), balance: formatCost(cost.used))
+ }
+
+ return nil
+ }
+
static func providerCostSection(
provider: UsageProvider,
cost: ProviderCostSnapshot?,
@@ -426,49 +486,12 @@ extension UsageMenuCardView.Model {
providerCurrency: providerCurrency ?? cost.currencyCode)
}
- if provider == .factory || provider == .devin, cost.period == "Extra usage balance" {
- let balance = formatCost(cost.used)
- return ProviderCostSection(
- title: L("Extra usage"),
- percentUsed: nil,
- spendLine: "\(L("Balance")): \(balance)",
- percentLine: nil)
- }
-
- if provider == .opencodego, cost.period == "Zen balance" {
- let balance = formatCost(cost.used)
- return ProviderCostSection(
- title: L("Zen balance"),
- percentUsed: nil,
- spendLine: "\(L("Balance")): \(balance)",
- percentLine: nil)
- }
-
- if provider == .minimax, cost.period == "MiniMax points balance" {
- let balance = String(format: "%.0f", cost.used)
- return ProviderCostSection(
- title: L("Credits"),
- percentUsed: nil,
- spendLine: "\(L("Balance")): \(balance)",
- percentLine: nil)
- }
-
- if provider == .xai, cost.period == "Prepaid credits" {
- let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
- return ProviderCostSection(
- title: L("Credits"),
- percentUsed: nil,
- spendLine: "\(L("Balance")): \(balance)",
- percentLine: nil)
- }
-
- if provider == .zenmux || provider == .neuralwatt {
- let balance = formatCost(cost.used)
- return ProviderCostSection(
- title: L("metric_mistral_payg"),
- percentUsed: nil,
- spendLine: "\(L("Balance")): \(balance)",
- percentLine: nil)
+ if let section = Self.balanceOnlyCostSection(
+ provider: provider,
+ cost: cost,
+ formatCost: { formatCost($0) })
+ {
+ return section
}
if provider == .claude {
diff --git a/Sources/CodexBar/Providers/Hyper/HyperProviderImplementation.swift b/Sources/CodexBar/Providers/Hyper/HyperProviderImplementation.swift
new file mode 100644
index 0000000000..c17eb745cc
--- /dev/null
+++ b/Sources/CodexBar/Providers/Hyper/HyperProviderImplementation.swift
@@ -0,0 +1,109 @@
+import AppKit
+import CodexBarCore
+import Foundation
+import SwiftUI
+
+struct HyperProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .hyper
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { context in
+ context.store.sourceLabel(for: context.provider)
+ }
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.hyperAPIKey
+ _ = settings.hyperCookieSource
+ _ = settings.hyperCookieHeader
+ _ = settings.tokenAccountsData(for: .hyper)
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .hyper(context.settings.hyperSettingsSnapshot())
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let binding = Binding(
+ get: { context.settings.hyperCookieSource.rawValue },
+ set: { raw in
+ context.settings.hyperCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let options = ProviderCookieSourceUI.options(
+ allowsOff: true,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+ let subtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.hyperCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Prefer a signed-in Hyper session from Chrome, then fall back to an API key.",
+ manual: "Paste a Cookie header from hyper.charm.land.",
+ off: "Use only the configured API key.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "hyper-cookie-source",
+ title: "Session source",
+ subtitle: "Prefer a signed-in Hyper session, then fall back to an API key.",
+ dynamicSubtitle: subtitle,
+ binding: binding,
+ options: options,
+ isVisible: nil,
+ onChange: nil,
+ trailingText: {
+ ProviderCookieRefreshAction.trailingText(
+ provider: .hyper,
+ cookieSource: context.settings.hyperCookieSource,
+ context: context)
+ },
+ trailingActions: [
+ ProviderCookieRefreshAction.descriptor(
+ provider: .hyper,
+ cookieSource: { context.settings.hyperCookieSource },
+ context: context),
+ ]),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "hyper-cookie",
+ title: "Hyper cookie",
+ subtitle: "Paste a Cookie header copied from a signed-in hyper.charm.land request.",
+ kind: .secure,
+ placeholder: "Cookie: ...",
+ binding: context.stringBinding(\.hyperCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "hyper-open-dashboard",
+ title: "Open Charm Hyper",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://hyper.charm.land") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.hyperCookieSource == .manual },
+ onActivate: nil),
+ ProviderSettingsFieldDescriptor(
+ id: "hyper-api-key",
+ title: "API key",
+ subtitle: "Fallback when no signed-in Hyper session is available. Stored in the CodexBar config file.",
+ kind: .secure,
+ placeholder: "Paste API key…",
+ binding: context.stringBinding(\.hyperAPIKey),
+ actions: [],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Hyper/HyperSettingsStore.swift b/Sources/CodexBar/Providers/Hyper/HyperSettingsStore.swift
new file mode 100644
index 0000000000..f5d141ceb7
--- /dev/null
+++ b/Sources/CodexBar/Providers/Hyper/HyperSettingsStore.swift
@@ -0,0 +1,40 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var hyperCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .hyper, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .hyper) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .hyper, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+
+ var hyperCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .hyper)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .hyper) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .hyper, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var hyperAPIKey: String {
+ get { self.configSnapshot.providerConfig(for: .hyper)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .hyper) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .hyper, field: "apiKey", value: newValue)
+ }
+ }
+
+ func hyperSettingsSnapshot() -> ProviderSettingsSnapshot.CookieProviderSettings {
+ ProviderSettingsSnapshot.CookieProviderSettings(
+ cookieSource: self.hyperCookieSource,
+ manualCookieHeader: self.hyperCookieHeader)
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index c811590992..47be002242 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -56,6 +56,7 @@ enum ProviderImplementationRegistry {
case .mistral: MistralProviderImplementation()
case .deepseek: DeepSeekProviderImplementation()
case .deepinfra: DeepInfraProviderImplementation()
+ case .hyper: HyperProviderImplementation()
case .codebuff: CodebuffProviderImplementation()
case .crof: CrofProviderImplementation()
case .venice: VeniceProviderImplementation()
diff --git a/Sources/CodexBar/Resources/ProviderIcon-hyper.svg b/Sources/CodexBar/Resources/ProviderIcon-hyper.svg
new file mode 100644
index 0000000000..25ea99e682
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-hyper.svg
@@ -0,0 +1,3 @@
+
diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift
index 7f28ffa86f..668bd9779c 100644
--- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift
+++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift
@@ -342,7 +342,7 @@ extension SettingsStore {
static func isBalanceOnlyProvider(_ provider: UsageProvider) -> Bool {
switch provider {
- case .deepseek, .deepinfra, .mistral, .moonshot, .poe:
+ case .deepseek, .deepinfra, .hyper, .mistral, .moonshot, .poe:
true
default:
false
diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift
index 209a4b2626..ee70542027 100644
--- a/Sources/CodexBar/StatusItemController+Animation.swift
+++ b/Sources/CodexBar/StatusItemController+Animation.swift
@@ -872,6 +872,11 @@ extension StatusItemController {
{
return balance
}
+ if provider == .hyper,
+ let balance = Self.hyperBalanceDisplayText(snapshot: snapshot)
+ {
+ return balance
+ }
if provider == .mimo,
let balance = Self.miMoBalanceDisplayText(
snapshot: snapshot,
@@ -1015,6 +1020,23 @@ extension StatusItemController {
return prefix + String(value)
}
+ nonisolated static func hyperBalanceDisplayText(snapshot: UsageSnapshot?) -> String? {
+ guard snapshot?.primary == nil,
+ snapshot?.secondary == nil,
+ let cost = snapshot?.providerCost,
+ cost.period == "Hypercredits balance",
+ let value = cost.balance,
+ value.isFinite,
+ value >= 0
+ else {
+ return nil
+ }
+ let balance = value.rounded() == value
+ ? String(format: "%.0f", value)
+ : String(format: "%.2f", value)
+ return "\(balance) HC"
+ }
+
nonisolated static func miMoBalanceDisplayText(
snapshot: UsageSnapshot?,
preference: MenuBarMetricPreference) -> String?
diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift
index e013db3189..9456b315d0 100644
--- a/Sources/CodexBar/UsageStore+Accessors.swift
+++ b/Sources/CodexBar/UsageStore+Accessors.swift
@@ -155,6 +155,8 @@ extension UsageStore {
return DeepSeekUsageError.missingCredentials.errorDescription
case .deepinfra:
return DeepInfraUsageError.missingCredentials.errorDescription
+ case .hyper:
+ return HyperUsageError.missingCredentials.errorDescription
case .perplexity:
return PerplexityAPIError.missingToken.errorDescription
case .minimax:
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 19c9488717..17de187f52 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -985,6 +985,7 @@ extension UsageStore {
.sakana: "Sakana AI debug log not yet implemented",
.venice: "Venice debug log not yet implemented",
.deepinfra: "DeepInfra debug log not yet implemented",
+ .hyper: "Charm Hyper debug log not yet implemented",
.commandcode: "Command Code debug log not yet implemented",
.qoder: "Qoder debug log not yet implemented",
.stepfun: "StepFun debug log not yet implemented",
@@ -1079,7 +1080,7 @@ extension UsageStore {
hasTokenAccount: deepSeekHasTokenAccount)
case .clinepass, .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .qwencloud, .factory,
.copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .moonshot, .jetbrains, .perplexity,
- .mimo, .doubao, .sakana, .abacus, .mistral, .deepinfra, .codebuff, .crof, .windsurf,
+ .mimo, .doubao, .sakana, .abacus, .mistral, .deepinfra, .hyper, .codebuff, .crof, .windsurf,
.venice, .manus, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy,
.litellm, .zed, .deepgram, .poe, .chutes, .neuralwatt, .clawrouter, .longcat, .wayfinder,
.sub2api, .zenmux, .aiand, .zoommate, .xai:
diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift
index 26a0e60984..2b5b28327e 100644
--- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift
+++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift
@@ -261,6 +261,8 @@ extension CodexBarCLI {
switch provider {
case .kimi:
KimiSettingsReader.apiKey(environment: environment) != nil
+ case .hyper:
+ HyperSettingsReader.apiKey(environment: environment) != nil
case .llmproxy:
LLMProxySettingsReader.apiKey(environment: environment) != nil
case .clawrouter:
diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift
index 6048c748f8..c094f8a368 100644
--- a/Sources/CodexBarCLI/CLIRenderer.swift
+++ b/Sources/CodexBarCLI/CLIRenderer.swift
@@ -621,6 +621,14 @@ enum CLIRenderer {
// pair; the dedicated balance line renders it instead.
!(provider == .xai && cost.period == "Prepaid credits")
else { return }
+ if provider == .hyper,
+ cost.period == "Hypercredits balance",
+ let value = cost.balance
+ {
+ let balance = Self.hypercreditsString(value)
+ lines.append(self.labelValueLine("Balance", value: "\(balance) HC", useColor: context.useColor))
+ return
+ }
// Fallback to cost/quota display if no primary rate window.
let label = cost.currencyCode == "Quota" ? "Quota" : "Cost"
let value = "\(String(format: "%.1f", cost.used)) / \(String(format: "%.1f", cost.limit))"
@@ -1043,6 +1051,11 @@ enum CLIRenderer {
}
}
+ private static func hypercreditsString(_ value: Double) -> String {
+ if value.rounded() == value { return String(format: "%.0f", value) }
+ return String(format: "%.2f", value)
+ }
+
private static func resetLineForDetailBackedWindow(
window: RateWindow,
style: ResetTimeDisplayStyle,
diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
index 0fbe433390..c099240648 100644
--- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
+++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
@@ -188,7 +188,7 @@ public enum ProviderConfigEnvironment {
case .llmproxy:
LLMProxySettingsReader.apiKeyEnvironmentKey
case .chutes, .poe, .litellm, .clawrouter, .factory, .sub2api, .neuralwatt, .zenmux, .deepinfra, .aiand,
- .xai:
+ .hyper, .xai:
self.additionalAPIKeyEnvironmentKey(for: provider)
default:
nil
@@ -215,6 +215,8 @@ public enum ProviderConfigEnvironment {
ZenMuxSettingsReader.managementAPIKeyEnvironmentKey
case .deepinfra:
DeepInfraSettingsReader.apiKeyEnvironmentKey
+ case .hyper:
+ HyperSettingsReader.apiKeyEnvironmentKey
case .aiand:
AiAndSettingsReader.apiKeyEnvironmentKey
case .xai:
diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift
index ca353e24bb..65e5e753a0 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 = "3aa49b47f4b78e13"
+ static let value = "87d9368d3ae2c687"
}
diff --git a/Sources/CodexBarCore/Providers/Hyper/HyperCookieImporter.swift b/Sources/CodexBarCore/Providers/Hyper/HyperCookieImporter.swift
new file mode 100644
index 0000000000..4776d285fd
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Hyper/HyperCookieImporter.swift
@@ -0,0 +1,112 @@
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+
+public enum HyperCookieImporter {
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["hyper.charm.land"]
+ private static let cookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.hyper]?.browserCookieOrder ?? [.chrome]
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public var cookieHeader: String {
+ self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
+ }
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
+ for browser in candidates {
+ do {
+ if let session = try self.importSessions(from: browser, logger: logger).first {
+ return session
+ }
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ logger?("[hyper-cookie] \(browser.displayName) cookie import failed: \(error.localizedDescription)")
+ }
+ }
+ throw HyperCookieImportError.noCookies
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ (try? self.importSession(browserDetection: browserDetection, logger: logger)) != nil
+ }
+
+ private static func importSessions(
+ from browser: Browser,
+ logger: ((String) -> Void)?) throws -> [SessionInfo]
+ {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let sources = try self.cookieClient.codexBarRecords(
+ matching: query,
+ in: browser,
+ logger: logger)
+ let groups = Dictionary(grouping: sources, by: { $0.store.profile.id })
+
+ return groups.values.compactMap { group in
+ let records = self.mergeRecords(group)
+ let cookies = BrowserCookieClient.makeHTTPCookies(records, origin: query.origin)
+ guard !cookies.isEmpty else { return nil }
+ let sourceLabel = group.map(\.label).min() ?? browser.displayName
+ logger?("[hyper-cookie] Found \(cookies.count) cookie(s) in \(sourceLabel)")
+ return SessionInfo(cookies: cookies, sourceLabel: sourceLabel)
+ }
+ .sorted { $0.sourceLabel < $1.sourceLabel }
+ }
+
+ private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] {
+ let sortedSources = sources.sorted { self.priority($0.store.kind) < self.priority($1.store.kind) }
+ var recordsByKey: [String: BrowserCookieRecord] = [:]
+ for source in sortedSources {
+ for record in source.records {
+ let key = "\(record.name)|\(record.domain)|\(record.path)"
+ if let current = recordsByKey[key] {
+ if self.shouldReplace(current, with: record) {
+ recordsByKey[key] = record
+ }
+ } else {
+ recordsByKey[key] = record
+ }
+ }
+ }
+ return recordsByKey.values.sorted { lhs, rhs in
+ (lhs.name, lhs.domain, lhs.path) < (rhs.name, rhs.domain, rhs.path)
+ }
+ }
+
+ private static func priority(_ kind: BrowserCookieStoreKind) -> Int {
+ switch kind {
+ case .network: 0
+ case .primary: 1
+ case .safari: 2
+ }
+ }
+
+ private static func shouldReplace(_ current: BrowserCookieRecord, with candidate: BrowserCookieRecord) -> Bool {
+ switch (current.expires, candidate.expires) {
+ case let (lhs?, rhs?): rhs > lhs
+ case (nil, .some): true
+ case (.some, nil), (nil, nil): false
+ }
+ }
+}
+
+enum HyperCookieImportError: LocalizedError {
+ case noCookies
+
+ var errorDescription: String? {
+ "No Charm Hyper browser session found. Sign in to hyper.charm.land or configure an API key."
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Hyper/HyperProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Hyper/HyperProviderDescriptor.swift
new file mode 100644
index 0000000000..b686cc5f67
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Hyper/HyperProviderDescriptor.swift
@@ -0,0 +1,153 @@
+import Foundation
+
+public enum HyperProviderDescriptor {
+ public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
+
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .hyper,
+ metadata: ProviderMetadata(
+ id: .hyper,
+ displayName: "Charm Hyper",
+ sessionLabel: "Balance",
+ weeklyLabel: "Balance",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Charm Hyper usage",
+ cliName: "hyper",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: ProviderBrowserCookieDefaults.chromeOnlyImportOrder,
+ dashboardURL: "https://hyper.charm.land",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .hyper,
+ iconResourceName: "ProviderIcon-hyper",
+ color: ProviderColor(red: 1, green: 96 / 255, blue: 1),
+ confettiPalette: [
+ ProviderColor(red: 1, green: 96 / 255, blue: 1),
+ ProviderColor(red: 1, green: 1, blue: 1),
+ ]),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Charm Hyper cost history is not available via API." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),
+ cli: ProviderCLIConfig(name: "hyper", aliases: [], versionDetector: nil))
+ }
+
+ private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] {
+ switch context.sourceMode {
+ case .web:
+ [HyperSessionFetchStrategy()]
+ case .api:
+ [HyperAPIFetchStrategy()]
+ case .auto:
+ if context.settings?.hyper?.cookieSource == .off {
+ [HyperAPIFetchStrategy()]
+ } else {
+ [HyperSessionFetchStrategy(), HyperAPIFetchStrategy()]
+ }
+ case .cli, .oauth:
+ []
+ }
+ }
+}
+
+struct HyperSessionFetchStrategy: ProviderFetchStrategy {
+ let id = "hyper.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ let cookieSource = context.settings?.hyper?.cookieSource ?? .auto
+ guard cookieSource != .off else { return false }
+ if cookieSource == .manual {
+ return CookieHeaderNormalizer.normalize(context.settings?.hyper?.manualCookieHeader) != nil
+ }
+ #if os(macOS)
+ if let cached = CookieHeaderCache.load(provider: .hyper),
+ !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ return true
+ }
+ return HyperCookieImporter.hasSession(browserDetection: context.browserDetection)
+ #else
+ return false
+ #endif
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let cookieHeader = try Self.resolveCookieHeader(context: context)
+ do {
+ let usage = try await HyperUsageFetcher.fetchUsage(cookieHeader: cookieHeader)
+ return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web")
+ } catch HyperUsageError.missingCredentials {
+ if context.settings?.hyper?.cookieSource != .manual {
+ CookieHeaderCache.clear(provider: .hyper)
+ }
+ throw HyperUsageError.missingCredentials
+ }
+ }
+
+ func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool {
+ guard context.sourceMode == .auto else { return false }
+ if error is CancellationError || (error as? URLError)?.code == .cancelled {
+ return false
+ }
+ guard let error = error as? HyperUsageError else { return true }
+ switch error {
+ case .missingCredentials, .networkError:
+ return true
+ case .apiError, .parseFailed:
+ return false
+ }
+ }
+
+ private static func resolveCookieHeader(context: ProviderFetchContext) throws -> String {
+ if context.settings?.hyper?.cookieSource == .manual {
+ guard let header = CookieHeaderNormalizer.normalize(context.settings?.hyper?.manualCookieHeader) else {
+ throw HyperUsageError.missingCredentials
+ }
+ return header
+ }
+
+ #if os(macOS)
+ if let cached = CookieHeaderCache.load(provider: .hyper),
+ !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ return cached.cookieHeader
+ }
+ let session = try HyperCookieImporter.importSession(browserDetection: context.browserDetection)
+ CookieHeaderCache.store(provider: .hyper, cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel)
+ return session.cookieHeader
+ #else
+ throw HyperUsageError.missingCredentials
+ #endif
+ }
+}
+
+struct HyperAPIFetchStrategy: ProviderFetchStrategy {
+ let id = "hyper.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_: ProviderFetchContext) async -> Bool {
+ // Keep this strategy available so a missing key yields provider-specific setup guidance.
+ true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = ProviderTokenResolver.hyperToken(environment: context.env) else {
+ throw HyperUsageError.missingCredentials
+ }
+ let usage = try await HyperUsageFetcher.fetchUsage(apiKey: apiKey)
+ return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Hyper/HyperSettingsReader.swift b/Sources/CodexBarCore/Providers/Hyper/HyperSettingsReader.swift
new file mode 100644
index 0000000000..27843460a9
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Hyper/HyperSettingsReader.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+public struct HyperSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKey = "HYPER_API_KEY"
+
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ guard var value = environment[apiKeyEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !value.isEmpty
+ else { return nil }
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ return value.isEmpty ? nil : value
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Hyper/HyperUsageFetcher.swift b/Sources/CodexBarCore/Providers/Hyper/HyperUsageFetcher.swift
new file mode 100644
index 0000000000..7b7f86eb53
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Hyper/HyperUsageFetcher.swift
@@ -0,0 +1,184 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct HyperCreditsResponse: Decodable, Sendable {
+ public let balance: Double
+}
+
+public struct HyperUsageSnapshot: Sendable {
+ public let balance: Double
+ public let updatedAt: Date
+
+ public init(balance: Double, updatedAt: Date) {
+ self.balance = balance
+ self.updatedAt = updatedAt
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ providerCost: ProviderCostSnapshot(
+ used: 0,
+ limit: 0,
+ currencyCode: "HC",
+ period: "Hypercredits balance",
+ balance: self.balance,
+ updatedAt: self.updatedAt),
+ updatedAt: self.updatedAt,
+ identity: ProviderIdentitySnapshot(
+ providerID: .hyper,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil),
+ dataConfidence: .exact)
+ }
+}
+
+public enum HyperUsageError: LocalizedError, Sendable, Equatable {
+ case missingCredentials
+ case networkError(String)
+ case apiError(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Sign in to hyper.charm.land or configure a Charm Hyper API key."
+ case let .networkError(message): "Charm Hyper network error: \(message)"
+ case let .apiError(message): "Charm Hyper API error: \(message)"
+ case let .parseFailed(message): "Failed to parse Charm Hyper response: \(message)"
+ }
+ }
+}
+
+public struct HyperUsageFetcher: Sendable {
+ private enum Authentication: Equatable {
+ case apiKey
+ case session
+ }
+
+ private static let creditsURL = URL(string: "https://hyper.charm.land/v1/credits")!
+
+ public static func fetchUsage(apiKey: String) async throws -> HyperUsageSnapshot {
+ let token = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !token.isEmpty else { throw HyperUsageError.missingCredentials }
+ return try await self.fetchUsage(
+ request: self.request(authorization: "Bearer \(token)"),
+ authentication: .apiKey,
+ transport: ProviderHTTPClient.shared,
+ now: Date())
+ }
+
+ public static func fetchUsage(cookieHeader: String) async throws -> HyperUsageSnapshot {
+ guard let header = CookieHeaderNormalizer.normalize(cookieHeader) else {
+ throw HyperUsageError.missingCredentials
+ }
+ return try await self.fetchUsage(
+ request: self.request(cookieHeader: header),
+ authentication: .session,
+ transport: ProviderHTTPClient.shared,
+ now: Date())
+ }
+
+ static func _fetchUsageForTesting(
+ apiKey: String,
+ transport: any ProviderHTTPTransport,
+ now: Date = Date()) async throws -> HyperUsageSnapshot
+ {
+ let token = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !token.isEmpty else { throw HyperUsageError.missingCredentials }
+ return try await self.fetchUsage(
+ request: self.request(authorization: "Bearer \(token)"),
+ authentication: .apiKey,
+ transport: transport,
+ now: now)
+ }
+
+ static func _fetchSessionForTesting(
+ cookieHeader: String,
+ transport: any ProviderHTTPTransport,
+ now: Date = Date()) async throws -> HyperUsageSnapshot
+ {
+ guard let header = CookieHeaderNormalizer.normalize(cookieHeader) else {
+ throw HyperUsageError.missingCredentials
+ }
+ return try await self.fetchUsage(
+ request: self.request(cookieHeader: header),
+ authentication: .session,
+ transport: transport,
+ now: now)
+ }
+
+ static func _parseSnapshotForTesting(_ data: Data, now: Date = Date()) throws -> HyperUsageSnapshot {
+ try self.parseSnapshot(data: data, now: now)
+ }
+
+ private static func fetchUsage(
+ request: URLRequest,
+ authentication: Authentication,
+ transport: any ProviderHTTPTransport,
+ now: Date) async throws -> HyperUsageSnapshot
+ {
+ do {
+ let response = try await transport.response(
+ for: request,
+ retryPolicy: .transientIdempotent)
+ try self.validate(response, authentication: authentication)
+ return try self.parseSnapshot(data: response.data, now: now)
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch let error as HyperUsageError {
+ throw error
+ } catch {
+ throw HyperUsageError.networkError(error.localizedDescription)
+ }
+ }
+
+ private static func request(authorization: String? = nil, cookieHeader: String? = nil) -> URLRequest {
+ var request = URLRequest(url: self.creditsURL)
+ request.httpMethod = "GET"
+ request.setValue(authorization, forHTTPHeaderField: "Authorization")
+ request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.timeoutInterval = 30
+ return request
+ }
+
+ private static func validate(_ response: ProviderHTTPResponse, authentication: Authentication) throws {
+ switch response.statusCode {
+ case 200:
+ if authentication == .session {
+ let contentType = response.response.value(forHTTPHeaderField: "Content-Type")?.lowercased() ?? ""
+ let path = response.response.url?.path.lowercased() ?? ""
+ if contentType.contains("text/html") || path.hasPrefix("/auth") {
+ throw HyperUsageError.missingCredentials
+ }
+ }
+ return
+ case 401:
+ if authentication == .session { throw HyperUsageError.missingCredentials }
+ throw HyperUsageError.apiError("API key rejected (HTTP 401).")
+ case 403:
+ if authentication == .session { throw HyperUsageError.missingCredentials }
+ throw HyperUsageError.apiError("API key cannot access credits (HTTP 403).")
+ default: throw HyperUsageError.apiError("HTTP \(response.statusCode)")
+ }
+ }
+
+ private static func parseSnapshot(data: Data, now: Date) throws -> HyperUsageSnapshot {
+ do {
+ let response = try JSONDecoder().decode(HyperCreditsResponse.self, from: data)
+ guard response.balance.isFinite, response.balance >= 0 else {
+ throw HyperUsageError.parseFailed("Balance must be a non-negative number.")
+ }
+ return HyperUsageSnapshot(balance: response.balance, updatedAt: now)
+ } catch let error as HyperUsageError {
+ throw error
+ } catch {
+ throw HyperUsageError.parseFailed(error.localizedDescription)
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 6e24825e55..49ed1c5652 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -166,6 +166,7 @@ public enum ProviderDescriptorRegistry {
.mistral: MistralProviderDescriptor.descriptor,
.deepseek: DeepSeekProviderDescriptor.descriptor,
.deepinfra: DeepInfraProviderDescriptor.descriptor,
+ .hyper: HyperProviderDescriptor.descriptor,
.codebuff: CodebuffProviderDescriptor.descriptor,
.crof: CrofProviderDescriptor.descriptor,
.venice: VeniceProviderDescriptor.descriptor,
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index 1f14ab28c5..ffbeef1506 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -41,6 +41,7 @@ public struct ProviderSettingsSnapshot: Sendable {
mimo: MiMoProviderSettings? = nil,
abacus: AbacusProviderSettings? = nil,
mistral: MistralProviderSettings? = nil,
+ hyper: CookieProviderSettings? = nil,
qoder: QoderProviderSettings? = nil,
stepfun: StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot
{
@@ -77,6 +78,7 @@ public struct ProviderSettingsSnapshot: Sendable {
mimo: mimo,
abacus: abacus,
mistral: mistral,
+ hyper: hyper,
qoder: qoder,
stepfun: stepfun)
}
@@ -524,6 +526,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let mimo: MiMoProviderSettings?
public let abacus: AbacusProviderSettings?
public let mistral: MistralProviderSettings?
+ public let hyper: CookieProviderSettings?
public let qoder: QoderProviderSettings?
public let stepfun: StepFunProviderSettings?
@@ -564,6 +567,7 @@ public struct ProviderSettingsSnapshot: Sendable {
mimo: MiMoProviderSettings? = nil,
abacus: AbacusProviderSettings? = nil,
mistral: MistralProviderSettings? = nil,
+ hyper: CookieProviderSettings? = nil,
qoder: QoderProviderSettings? = nil,
stepfun: StepFunProviderSettings? = nil)
{
@@ -599,6 +603,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.mimo = mimo
self.abacus = abacus
self.mistral = mistral
+ self.hyper = hyper
self.qoder = qoder
self.stepfun = stepfun
}
@@ -635,6 +640,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case mimo(ProviderSettingsSnapshot.MiMoProviderSettings)
case abacus(ProviderSettingsSnapshot.AbacusProviderSettings)
case mistral(ProviderSettingsSnapshot.MistralProviderSettings)
+ case hyper(ProviderSettingsSnapshot.CookieProviderSettings)
case qoder(ProviderSettingsSnapshot.QoderProviderSettings)
case stepfun(ProviderSettingsSnapshot.StepFunProviderSettings)
}
@@ -672,6 +678,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var mimo: ProviderSettingsSnapshot.MiMoProviderSettings?
public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings?
public var mistral: ProviderSettingsSnapshot.MistralProviderSettings?
+ public var hyper: ProviderSettingsSnapshot.CookieProviderSettings?
public var qoder: ProviderSettingsSnapshot.QoderProviderSettings?
public var stepfun: ProviderSettingsSnapshot.StepFunProviderSettings?
@@ -713,6 +720,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .mimo(value): self.mimo = value
case let .abacus(value): self.abacus = value
case let .mistral(value): self.mistral = value
+ case let .hyper(value): self.hyper = value
case let .qoder(value): self.qoder = value
case let .stepfun(value): self.stepfun = value
}
@@ -752,6 +760,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
mimo: self.mimo,
abacus: self.abacus,
mistral: self.mistral,
+ hyper: self.hyper,
qoder: self.qoder,
stepfun: self.stepfun)
}
diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
index b21c177ffc..27a78ac1a1 100644
--- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
+++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
@@ -167,6 +167,12 @@ public enum ProviderTokenResolver {
self.deepInfraResolution(environment: environment)?.token
}
+ public static func hyperToken(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ self.hyperResolution(environment: environment)?.token
+ }
+
public static func stepfunToken(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
@@ -207,6 +213,12 @@ public enum ProviderTokenResolver {
self.resolveEnv(DeepInfraSettingsReader.apiKey(environment: environment))
}
+ public static func hyperResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(HyperSettingsReader.apiKey(environment: environment))
+ }
+
public static func poeResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 7033a5ff29..8044ad9c04 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -46,6 +46,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case mistral
case deepseek
case deepinfra
+ case hyper
case codebuff
case crof
case venice
@@ -115,6 +116,7 @@ public enum IconStyle: String, Sendable, CaseIterable {
case mistral
case deepseek
case deepinfra
+ case hyper
case codebuff
case crof
case venice
diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
index ae2807a2fe..bdab83b4db 100644
--- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
+++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
@@ -38,6 +38,13 @@ extension TokenAccountSupportCatalog {
injection: .environment(key: DeepInfraSettingsReader.apiKeyEnvironmentKey),
requiresManualCookieSource: false,
cookieName: nil),
+ .hyper: TokenAccountSupport(
+ title: "API keys",
+ subtitle: "Store multiple Charm Hyper API keys.",
+ placeholder: "Paste API key…",
+ injection: .environment(key: HyperSettingsReader.apiKeyEnvironmentKey),
+ requiresManualCookieSource: false,
+ cookieName: nil),
.antigravity: TokenAccountSupport(
title: "Google accounts",
subtitle: "Store multiple Antigravity Google OAuth accounts for quick switching.",
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index fe2677734a..22fbffdfeb 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -1043,7 +1043,7 @@ enum CostUsageScanner {
.alibabatokenplan, .qwencloud, .factory,
.copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .moonshot, .augment, .jetbrains, .amp,
.ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana,
- .abacus, .mistral, .deepseek, .deepinfra, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode,
+ .abacus, .mistral, .deepseek, .deepinfra, .hyper, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode,
.qoder, .stepfun, .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .neuralwatt,
.clawrouter, .longcat, .sub2api, .wayfinder, .zenmux, .aiand, .zoommate, .xai:
return emptyReport
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 86acf1caea..af09df87d4 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -115,6 +115,7 @@ enum ProviderChoice: String, AppEnum {
case .mistral: self = .mistral
case .deepseek: return nil // DeepSeek not yet supported in widgets
case .deepinfra: return nil // DeepInfra not yet supported in widgets
+ case .hyper: return nil // Charm Hyper not yet supported in widgets
case .codebuff: return nil // Codebuff not yet supported in widgets
case .crof: return nil // Crof not yet supported in widgets
case .venice: return nil // Venice not yet supported in widgets
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 6cfaee31ee..6b6a91d783 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -345,6 +345,7 @@ private struct ProviderSwitchChip: View {
case .mistral: "Mistral"
case .deepseek: "DeepSeek"
case .deepinfra: "DeepInfra"
+ case .hyper: "Hyper"
case .codebuff: "Codebuff"
case .crof: "Crof"
case .venice: "Venice"
@@ -1087,6 +1088,8 @@ enum WidgetColors {
Color(red: 82 / 255, green: 125 / 255, blue: 240 / 255)
case .deepinfra:
Color(red: 42 / 255, green: 50 / 255, blue: 117 / 255)
+ case .hyper:
+ Color(red: 1, green: 96 / 255, blue: 1)
case .codebuff:
Color(red: 68 / 255, green: 255 / 255, blue: 0 / 255) // Codebuff lime
case .crof:
diff --git a/Tests/CodexBarTests/HyperPresentationTests.swift b/Tests/CodexBarTests/HyperPresentationTests.swift
new file mode 100644
index 0000000000..e8190331ab
--- /dev/null
+++ b/Tests/CodexBarTests/HyperPresentationTests.swift
@@ -0,0 +1,68 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+@testable import CodexBarCLI
+
+struct HyperPresentationTests {
+ @Test
+ func `CLI renders Hypercredits as a balance instead of a zero-limit cost`() {
+ let snapshot = HyperUsageSnapshot(balance: 42.5, updatedAt: Date(timeIntervalSince1970: 1))
+ .toUsageSnapshot()
+
+ let output = CLIRenderer.renderText(
+ provider: .hyper,
+ snapshot: snapshot,
+ credits: nil,
+ context: RenderContext(
+ header: "hyper",
+ status: nil,
+ useColor: false,
+ resetStyle: .absolute))
+
+ #expect(output.contains("Balance: 42.50 HC"))
+ #expect(!output.contains("Cost:"))
+ #expect(!output.contains("/ 0"))
+ }
+
+ @Test
+ func `menu bar renders the Hypercredit balance`() {
+ let snapshot = HyperUsageSnapshot(balance: 42.5, updatedAt: Date(timeIntervalSince1970: 1))
+ .toUsageSnapshot()
+
+ #expect(StatusItemController.hyperBalanceDisplayText(snapshot: snapshot) == "42.50 HC")
+ }
+
+ @Test
+ @MainActor
+ func `menu card renders Hypercredits as a balance without a percentage`() throws {
+ let now = Date(timeIntervalSince1970: 1)
+ let snapshot = HyperUsageSnapshot(balance: 42.5, updatedAt: now).toUsageSnapshot()
+ let metadata = try #require(ProviderDefaults.metadata[.hyper])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .hyper,
+ 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: true,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ #expect(model.providerCost?.title == "Hypercredits")
+ #expect(model.providerCost?.spendLine == "Balance: 42.50 HC")
+ #expect(model.providerCost?.percentUsed == nil)
+ #expect(model.providerCost?.percentLine == nil)
+ }
+}
diff --git a/Tests/CodexBarTests/HyperSettingsReaderTests.swift b/Tests/CodexBarTests/HyperSettingsReaderTests.swift
new file mode 100644
index 0000000000..ba7d3ef1c0
--- /dev/null
+++ b/Tests/CodexBarTests/HyperSettingsReaderTests.swift
@@ -0,0 +1,71 @@
+import Testing
+@testable import CodexBarCore
+
+struct HyperSettingsReaderTests {
+ @Test
+ func `reads and cleans Hyper API key from environment`() {
+ #expect(HyperSettingsReader.apiKey(environment: ["HYPER_API_KEY": " 'hyper-key' "]) == "hyper-key")
+ }
+
+ @Test
+ func `API key only disables the session strategy`() async {
+ let descriptor = ProviderDescriptorRegistry.descriptor(for: .hyper)
+ let environment = ["HYPER_API_KEY": "hyper-key"]
+ let settings = ProviderSettingsSnapshot.make(
+ hyper: .init(cookieSource: .off, manualCookieHeader: nil))
+ let context = Self.context(environment: environment, settings: settings)
+ let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context)
+
+ #expect(descriptor.metadata.displayName == "Charm Hyper")
+ #expect(descriptor.metadata.cliName == "hyper")
+ #expect(descriptor.metadata.dashboardURL == "https://hyper.charm.land")
+ #expect(strategies.map(\.id) == ["hyper.api"])
+ }
+
+ @Test
+ func `signed in session is preferred over an API key`() async {
+ let settings = ProviderSettingsSnapshot.make(
+ hyper: .init(cookieSource: .manual, manualCookieHeader: "session=fixture"))
+ let context = Self.context(
+ environment: ["HYPER_API_KEY": "hyper-key"],
+ settings: settings)
+ let strategies = await HyperProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies(context)
+
+ #expect(strategies.map(\.id) == ["hyper.web", "hyper.api"])
+ #expect(await strategies[0].isAvailable(context))
+ }
+
+ @Test
+ func `missing session and API key return setup guidance`() async {
+ let settings = ProviderSettingsSnapshot.make(
+ hyper: .init(cookieSource: .off, manualCookieHeader: nil))
+ let context = Self.context(environment: [:], settings: settings)
+ let strategies = await HyperProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies(context)
+
+ #expect(strategies.map(\.id) == ["hyper.api"])
+ await #expect(throws: HyperUsageError.missingCredentials) {
+ try await strategies[0].fetch(context)
+ }
+ #expect(HyperUsageError.missingCredentials.errorDescription?.contains("Sign in") == true)
+ #expect(HyperUsageError.missingCredentials.errorDescription?.contains("API key") == true)
+ }
+
+ private static func context(
+ environment: [String: String],
+ settings: ProviderSettingsSnapshot) -> ProviderFetchContext
+ {
+ let browserDetection = BrowserDetection(cacheTTL: 0)
+ return ProviderFetchContext(
+ runtime: .app,
+ sourceMode: .auto,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: environment,
+ settings: settings,
+ fetcher: UsageFetcher(environment: environment),
+ claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
+ browserDetection: browserDetection)
+ }
+}
diff --git a/Tests/CodexBarTests/HyperUsageFetcherTests.swift b/Tests/CodexBarTests/HyperUsageFetcherTests.swift
new file mode 100644
index 0000000000..64c379ea7b
--- /dev/null
+++ b/Tests/CodexBarTests/HyperUsageFetcherTests.swift
@@ -0,0 +1,137 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+import Testing
+@testable import CodexBarCore
+
+struct HyperUsageFetcherTests {
+ @Test
+ func `parses a non-negative Hypercredit balance`() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let snapshot = try HyperUsageFetcher._parseSnapshotForTesting(Data(#"{"balance":42.5}"#.utf8), now: now)
+
+ #expect(snapshot.balance == 42.5)
+ #expect(snapshot.updatedAt == now)
+ #expect(snapshot.toUsageSnapshot().providerCost?.balance == 42.5)
+ #expect(snapshot.toUsageSnapshot().providerCost?.used == 0)
+ #expect(snapshot.toUsageSnapshot().providerCost?.limit == 0)
+ #expect(snapshot.toUsageSnapshot().providerCost?.period == "Hypercredits balance")
+ #expect(snapshot.toUsageSnapshot().identity?.providerID == .hyper)
+ }
+
+ @Test
+ func `fetches credits with a signed in session cookie`() async throws {
+ let recorder = HyperRequestRecorder()
+ let transport = ProviderHTTPTransportHandler { request in
+ await recorder.append(request)
+ let response = try HTTPURLResponse(
+ url: #require(request.url),
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil)!
+ return (Data(#"{"balance":18.5}"#.utf8), response)
+ }
+
+ let snapshot = try await HyperUsageFetcher._fetchSessionForTesting(
+ cookieHeader: "Cookie: session=fixture-session",
+ transport: transport)
+ let request = try #require(await recorder.values.first)
+
+ #expect(snapshot.balance == 18.5)
+ #expect(request.url?.absoluteString == "https://hyper.charm.land/v1/credits")
+ #expect(request.value(forHTTPHeaderField: "Cookie") == "session=fixture-session")
+ #expect(request.value(forHTTPHeaderField: "Authorization") == nil)
+ }
+
+ @Test
+ func `session redirect to login is treated as missing credentials`() async {
+ let transport = ProviderHTTPTransportHandler { _ in
+ let response = try HTTPURLResponse(
+ url: #require(URL(string: "https://hyper.charm.land/auth")),
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "text/html; charset=utf-8"])!
+ return (Data("Log in".utf8), response)
+ }
+
+ await #expect(throws: HyperUsageError.missingCredentials) {
+ try await HyperUsageFetcher._fetchSessionForTesting(
+ cookieHeader: "session=expired",
+ transport: transport)
+ }
+ }
+
+ @Test
+ func `fetches documented credits endpoint with a bearer API key`() async throws {
+ let recorder = HyperRequestRecorder()
+ let transport = ProviderHTTPTransportHandler { request in
+ await recorder.append(request)
+ let response = try HTTPURLResponse(
+ url: #require(request.url),
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil)!
+ return (Data(#"{"balance":12}"#.utf8), response)
+ }
+
+ let snapshot = try await HyperUsageFetcher._fetchUsageForTesting(apiKey: "fixture-token", transport: transport)
+ let request = try #require(await recorder.values.first)
+
+ #expect(snapshot.balance == 12)
+ #expect(request.url?.scheme == "https")
+ #expect(request.url?.host == "hyper.charm.land")
+ #expect(request.url?.path == "/v1/credits")
+ #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token")
+ #expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
+ }
+
+ @Test(arguments: [
+ "",
+ "{}",
+ #"{"balance":"#,
+ #"{"balance":-1}"#,
+ #"{"balance":"invalid"}"#,
+ ])
+ func `rejects invalid balances`(payload: String) {
+ #expect(throws: HyperUsageError.self) {
+ try HyperUsageFetcher._parseSnapshotForTesting(Data(payload.utf8))
+ }
+ }
+
+ @Test
+ func `rejects empty credentials before making a request`() async {
+ let transport = ProviderHTTPTransportHandler { _ in
+ Issue.record("Transport should not be called for empty credentials")
+ throw HyperUsageError.networkError("unexpected request")
+ }
+
+ await #expect(throws: HyperUsageError.missingCredentials) {
+ try await HyperUsageFetcher._fetchUsageForTesting(apiKey: " ", transport: transport)
+ }
+ }
+
+ @Test
+ func `reports rejected API keys without exposing the key`() async {
+ let transport = ProviderHTTPTransportHandler { request in
+ let response = try HTTPURLResponse(
+ url: #require(request.url),
+ statusCode: 401,
+ httpVersion: nil,
+ headerFields: nil)!
+ return (Data(), response)
+ }
+
+ await #expect(throws: HyperUsageError.apiError("API key rejected (HTTP 401).")) {
+ try await HyperUsageFetcher._fetchUsageForTesting(apiKey: "secret-fixture", transport: transport)
+ }
+ }
+}
+
+private actor HyperRequestRecorder {
+ private(set) var values: [URLRequest] = []
+
+ func append(_ request: URLRequest) {
+ self.values.append(request)
+ }
+}
diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
index 064e4c33a9..2d5cd637a7 100644
--- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift
+++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
@@ -44,6 +44,7 @@ struct ProviderIconResourcesTests {
"zenmux",
"aiand",
"zoommate",
+ "hyper",
"xai",
]
for slug in slugs {
diff --git a/docs/configuration.md b/docs/configuration.md
index 0c72cb3ff7..2e80755e80 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -270,7 +270,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`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`.
+`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `hyper`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`.
## Ordering
The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering.
diff --git a/docs/hyper.md b/docs/hyper.md
new file mode 100644
index 0000000000..f717979ce6
--- /dev/null
+++ b/docs/hyper.md
@@ -0,0 +1,21 @@
+---
+summary: "Charm Hyper provider setup and Hypercredit balance display."
+---
+
+# Charm Hyper provider
+
+CodexBar reads the remaining Charm Hypercredit balance through Charm Hyper's maintained credits endpoint.
+
+## Authentication
+
+In **Auto** mode, CodexBar prefers a signed-in `hyper.charm.land` browser session and falls back to an API key. Automatic session import is Chrome-only; choose **Manual** to paste a Cookie header or **Off** to use only an API key.
+
+For API-key access, create a Charm Hyper API key and add it under **Settings > Providers > Charm Hyper > API key**, or set `HYPER_API_KEY` in the environment used to launch CodexBar. Token accounts are also supported.
+
+Both strategies request `GET https://hyper.charm.land/v1/credits`. Session cookies are sent only to `hyper.charm.land`; API keys are sent only as Bearer tokens to the same host.
+
+## Display
+
+CodexBar displays the returned `balance` in native HC units. The credits response does not establish a plan limit or reset timestamp, so CodexBar does not infer a usage percentage, quota, or reset countdown.
+
+If neither a usable browser session nor an API key is available, CodexBar reports setup guidance instead of showing an empty balance.
diff --git a/docs/index.html b/docs/index.html
index 2bb7f382b3..c2d73f5bb0 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -6,7 +6,7 @@
Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus. @@ -332,6 +332,7 @@
MistralCookies
DeepSeekAPI key
DeepInfraAPI key
Charm HyperAPI key + Cookies
T3 ChatCookies
CodebuffAPI key
PoeAPI key
66 providers·usage windows, credits, resets·one status item each, or merged.
+67 providers·usage windows, credits, resets·one status item each, or merged.