Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.43.1 — Unreleased

### Added
- AnyRouter: add credit balance, lifetime spend, and multi-account API keys from the AnyRouter credits API. Thanks @duyet!
- ZenMux: add Management API usage with five-hour and weekly quotas, subscription expiry, and USD PAYG balance. Thanks @kays0x!

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow.
- [Warp](docs/warp.md) — API token for GraphQL request limits and monthly credits.
- [ElevenLabs](docs/elevenlabs.md) — API key for character credits and voice slot usage.
- [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers.
- [AnyRouter](docs/anyrouter.md) — API key for credit balance and spend across the AnyRouter gateway.
- [CrossModel](docs/crossmodel.md) — API key wallet balance with daily, weekly, and monthly spend.
- [Windsurf](docs/windsurf.md) — Browser localStorage session import or local SQLite cache for plan usage.
- [Zed](docs/zed.md) — Zed editor Keychain session for plan, edit-prediction quota, billing cycle, and overdue invoices.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import CodexBarCore
import Foundation

struct AnyRouterProviderImplementation: ProviderImplementation {
let id: UsageProvider = .anyrouter

@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { _ in "api" }
}

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.tokenAccountsData(for: .anyrouter)
}

@MainActor
func isAvailable(context: ProviderAvailabilityContext) -> Bool {
if AnyRouterSettingsReader.apiKey(environment: context.environment) != nil {
return true
}
return !context.settings.tokenAccounts(for: .anyrouter).isEmpty
}

@MainActor
func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ enum ProviderImplementationRegistry {
case .clawrouter: ClawRouterProviderImplementation()
case .sub2api: Sub2APIProviderImplementation()
case .wayfinder: WayfinderProviderImplementation()
case .anyrouter: AnyRouterProviderImplementation()
case .zenmux: ZenMuxProviderImplementation()
}
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-anyrouter.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions Sources/CodexBar/UsageStore+APIKeyDebug.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ extension UsageStore {
hasEnvToken: { OpenRouterSettingsReader.apiToken(environment: $0) != nil })
}

func anyRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext {
self.apiKeyDebugContext(
provider: .anyrouter,
label: "ANYROUTER_API_KEY",
processEnvironment: processEnvironment,
resolution: ProviderTokenResolver.anyRouterResolution,
hasEnvToken: { AnyRouterSettingsReader.apiKey(environment: $0) != nil })
}

func crossModelAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext {
self.apiKeyDebugContext(
provider: .crossmodel,
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/UsageStore+Accessors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ extension UsageStore {
return ZaiSettingsError.missingToken.errorDescription
case .openrouter:
return OpenRouterSettingsError.missingToken.errorDescription
case .anyrouter:
return AnyRouterUsageError.missingCredentials.errorDescription
case .crossmodel:
return CrossModelSettingsError.missingToken.errorDescription
case .clawrouter:
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,7 @@ extension UsageStore {
let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment)
let openRouterDebugContext = self.openRouterAPIKeyDebugContext(processEnvironment: processEnvironment)
let crossModelDebugContext = self.crossModelAPIKeyDebugContext(processEnvironment: processEnvironment)
let anyRouterDebugContext = self.anyRouterAPIKeyDebugContext(processEnvironment: processEnvironment)
let elevenLabsDebugContext = self.elevenLabsAPIKeyDebugContext(processEnvironment: processEnvironment)
let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil
let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil
Expand Down Expand Up @@ -1135,6 +1136,8 @@ extension UsageStore {
ollamaCookieHeader: ollamaCookieHeader)
case .openrouter:
return Self.apiKeyDebugLine(openRouterDebugContext)
case .anyrouter:
return Self.apiKeyDebugLine(anyRouterDebugContext)
case .crossmodel:
return Self.apiKeyDebugLine(crossModelDebugContext)
case .elevenlabs:
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCLI/CLIDiagnoseCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ extension CodexBarCLI {
ClawRouterSettingsReader.apiKey(environment: environment) != nil
case .sub2api:
Sub2APISettingsReader.apiKey(environment: environment) != nil
case .anyrouter:
AnyRouterSettingsReader.apiKey(environment: environment) != nil
case .moonshot:
MoonshotSettingsReader.apiKey(environment: environment) != nil
case .ollama:
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ public enum ProviderConfigEnvironment {
SyntheticSettingsReader.apiKeyKey
case .openrouter:
OpenRouterSettingsReader.envKey
case .anyrouter:
AnyRouterSettingsReader.apiKeyEnvironmentKey
case .elevenlabs:
ElevenLabsSettingsReader.apiKeyEnvironmentKey
case .moonshot:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "9b26fd821cf090dc"
static let value = "b77f792063d8b794"
}
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Logging/LogCategories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public enum LogCategories {
public static let adaptiveRefresh = "adaptive-refresh"
public static let amp = "amp"
public static let antigravity = "antigravity"
public static let anyRouterUsage = "anyrouter-usage"
public static let app = "app"
public static let auggieCLI = "auggie-cli"
public static let augment = "augment"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

public enum AnyRouterProviderDescriptor {
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()

static func makeDescriptor() -> ProviderDescriptor {
ProviderDescriptor(
id: .anyrouter,
metadata: ProviderMetadata(
id: .anyrouter,
displayName: "AnyRouter",
sessionLabel: "Credits",
weeklyLabel: "Usage",
opusLabel: nil,
supportsOpus: false,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show AnyRouter usage",
cliName: "anyrouter",
defaultEnabled: false,
isPrimaryProvider: false,
usesAccountFallback: false,
dashboardURL: "https://anyrouter.dev/dashboard/credits",
statusPageURL: nil),
branding: ProviderBranding(
iconStyle: .anyrouter,
iconResourceName: "ProviderIcon-anyrouter",
color: ProviderColor(red: 26 / 255, green: 26 / 255, blue: 46 / 255)),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "AnyRouter spend is reported by its credits API." }),
fetchPlan: .apiToken(
strategyID: "anyrouter.api",
resolveToken: { ProviderTokenResolver.anyRouterToken(environment: $0) },
missingCredentialsError: { AnyRouterUsageError.missingCredentials },
loadUsage: { apiKey, context in
try AnyRouterSettingsReader.validateEndpointOverride(environment: context.env)
return try await AnyRouterUsageFetcher.fetchUsage(
apiKey: apiKey,
baseURL: AnyRouterSettingsReader.baseURL(environment: context.env)).toUsageSnapshot()
}),
cli: ProviderCLIConfig(
name: "anyrouter",
aliases: ["ar"],
versionDetector: nil))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation

public enum AnyRouterSettingsError: LocalizedError, Equatable, Sendable {
case invalidEndpointOverride(String)

public var errorDescription: String? {
switch self {
case let .invalidEndpointOverride(key):
"AnyRouter endpoint override \(key) must use HTTPS or a bare host."
}
}
}

/// Reads AnyRouter settings from environment variables.
public enum AnyRouterSettingsReader {
public static let apiKeyEnvironmentKey = "ANYROUTER_API_KEY"
public static let baseURLEnvironmentKey = "ANYROUTER_API_URL"
public static let defaultBaseURL = URL(string: "https://anyrouter.dev/api/v1")!

public static func apiKey(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
self.cleaned(environment[self.apiKeyEnvironmentKey])
}

public static func baseURL(
environment: [String: String] = ProcessInfo.processInfo.environment) -> URL
{
guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else {
return self.defaultBaseURL
}
return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) ?? self.defaultBaseURL
}

public static func validateEndpointOverride(
environment: [String: String] = ProcessInfo.processInfo.environment) throws
{
guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { return }
guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) != nil else {
throw AnyRouterSettingsError.invalidEndpointOverride(self.baseURLEnvironmentKey)
}
}

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
}
}
154 changes: 154 additions & 0 deletions Sources/CodexBarCore/Providers/AnyRouter/AnyRouterUsageFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public enum AnyRouterUsageError: LocalizedError, Equatable, Sendable {
case missingCredentials
case invalidCredentials
case insufficientScope
case apiError(Int)
case parseFailed(String)

public var errorDescription: String? {
switch self {
case .missingCredentials:
"Missing AnyRouter API key. Add one in Settings or set ANYROUTER_API_KEY."
case .invalidCredentials:
"AnyRouter rejected the API key. Check the key on the AnyRouter dashboard."
case .insufficientScope:
"This AnyRouter key is not permitted to read credits. Allow the /api/v1/credits endpoint "
+ "on the key, or use a key without an endpoint allow-list."
case let .apiError(statusCode):
"AnyRouter API returned HTTP \(statusCode)."
case let .parseFailed(message):
"Could not parse AnyRouter credits: \(message)"
}
}
}

/// `GET /api/v1/credits` response. AnyRouter returns the balance fields at the top level
/// (unlike OpenRouter, which wraps them in a `data` object). The payload also carries
/// `monthly_balance`, `topup_balance`, and `today_cost`, which nothing displays yet.
private struct AnyRouterCreditsResponse: Decodable {
let balance: Double
let used: Double
let currency: String?
}

public struct AnyRouterUsageSnapshot: Codable, Sendable, Equatable {
/// Total credit available to spend, in `currencyCode` (AnyRouter-issued plus purchased).
public let balance: Double
/// Cumulative lifetime spend.
public let used: Double
public let currencyCode: String
public let updatedAt: Date

public init(
balance: Double,
used: Double,
currencyCode: String,
updatedAt: Date)
{
self.balance = balance
self.used = used
self.currencyCode = currencyCode
self.updatedAt = updatedAt
}

/// Everything ever granted or purchased: what is still spendable plus what has been spent.
public var totalCredits: Double {
max(0, self.balance + self.used)
}

/// Share of granted credit already spent (0-100).
public var usedPercent: Double {
let total = self.totalCredits
guard total > 0 else { return 0 }
return min(100, max(0, self.used / total * 100))
}

public func toUsageSnapshot() -> UsageSnapshot {
let identity = ProviderIdentitySnapshot(
providerID: .anyrouter,
accountEmail: nil,
accountOrganization: nil,
loginMethod: "Balance: \(UsageFormatter.currencyString(self.balance, currencyCode: self.currencyCode))")

return UsageSnapshot(
primary: RateWindow(
usedPercent: self.usedPercent,
windowMinutes: nil,
resetsAt: nil,
resetDescription: nil),
secondary: nil,
providerCost: ProviderCostSnapshot(
used: self.used,
limit: self.totalCredits,
currencyCode: self.currencyCode,
period: "Lifetime",
updatedAt: self.updatedAt),
updatedAt: self.updatedAt,
identity: identity,
dataConfidence: .exact)
}
}

public enum AnyRouterUsageFetcher {
private static let log = CodexBarLog.logger(LogCategories.anyRouterUsage)
private static let requestTimeoutSeconds: TimeInterval = 15

public static func fetchUsage(
apiKey: String,
baseURL: URL = AnyRouterSettingsReader.defaultBaseURL,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
updatedAt: Date = Date()) async throws -> AnyRouterUsageSnapshot
{
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw AnyRouterUsageError.missingCredentials
}

var request = URLRequest(url: baseURL.appendingPathComponent("credits"))
request.httpMethod = "GET"
request.timeoutInterval = Self.requestTimeoutSeconds
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")

let response = try await transport.response(for: request)
// A key carrying an `allowed_endpoints` list that omits /api/v1/credits is valid but
// scoped out, which AnyRouter reports as 403 insufficient_scope — a different fix for
// the user than a rejected key.
if response.statusCode == 403 {
throw AnyRouterUsageError.insufficientScope
}
if response.statusCode == 401 {
throw AnyRouterUsageError.invalidCredentials
}
guard (200..<300).contains(response.statusCode) else {
Self.log.error("AnyRouter credits API returned \(response.statusCode)")
throw AnyRouterUsageError.apiError(response.statusCode)
}
return try self.parseSnapshot(data: response.data, updatedAt: updatedAt)
}

public static func _parseSnapshotForTesting(
_ data: Data,
updatedAt: Date) throws -> AnyRouterUsageSnapshot
{
try self.parseSnapshot(data: data, updatedAt: updatedAt)
}

private static func parseSnapshot(data: Data, updatedAt: Date) throws -> AnyRouterUsageSnapshot {
do {
let response = try JSONDecoder().decode(AnyRouterCreditsResponse.self, from: data)
return AnyRouterUsageSnapshot(
balance: response.balance,
used: response.used,
currencyCode: response.currency?.uppercased() ?? "USD",
updatedAt: updatedAt)
} catch {
self.log.error("AnyRouter credits parsing failed: \(error.localizedDescription)")
throw AnyRouterUsageError.parseFailed(error.localizedDescription)
}
}
}
Loading
Loading