diff --git a/Sources/CodexBar/Providers/CheapestInference/CheapestInferenceProviderImplementation.swift b/Sources/CodexBar/Providers/CheapestInference/CheapestInferenceProviderImplementation.swift new file mode 100644 index 000000000..d024fe70a --- /dev/null +++ b/Sources/CodexBar/Providers/CheapestInference/CheapestInferenceProviderImplementation.swift @@ -0,0 +1,57 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct CheapestInferenceProviderImplementation: ProviderImplementation { + let id: UsageProvider = .cheapestinference + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.cheapestInferenceAPIToken + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return nil + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if CheapestInferenceSettingsReader.apiToken(environment: context.environment) != nil { + return true + } + return !context.settings.cheapestInferenceAPIToken + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "cheapestinference-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. " + + "Get your key from cheapestinference.com/dashboard.", + kind: .secure, + placeholder: "sk-...", + binding: context.stringBinding(\.cheapestInferenceAPIToken), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/CheapestInference/CheapestInferenceSettingsStore.swift b/Sources/CodexBar/Providers/CheapestInference/CheapestInferenceSettingsStore.swift new file mode 100644 index 000000000..aeb0d3a0f --- /dev/null +++ b/Sources/CodexBar/Providers/CheapestInference/CheapestInferenceSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var cheapestInferenceAPIToken: String { + get { self.configSnapshot.providerConfig(for: .cheapestinference)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .cheapestinference) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .cheapestinference, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-cheapestinference.svg b/Sources/CodexBar/Resources/ProviderIcon-cheapestinference.svg new file mode 100644 index 000000000..38695efc7 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-cheapestinference.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 37a7726ef..6663e48a0 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -43,6 +43,7 @@ public enum LogCategories { public static let ollama = "ollama" public static let opencodeUsage = "opencode-usage" public static let openRouterUsage = "openrouter-usage" + public static let cheapestInferenceUsage = "cheapestinference-usage" public static let providerDetection = "provider-detection" public static let providers = "providers" public static let sessionQuota = "sessionQuota" diff --git a/Sources/CodexBarCore/Providers/CheapestInference/CheapestInferenceProviderDescriptor.swift b/Sources/CodexBarCore/Providers/CheapestInference/CheapestInferenceProviderDescriptor.swift new file mode 100644 index 000000000..a01735f50 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CheapestInference/CheapestInferenceProviderDescriptor.swift @@ -0,0 +1,84 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum CheapestInferenceProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .cheapestinference, + metadata: ProviderMetadata( + id: .cheapestinference, + displayName: "CheapestInference", + sessionLabel: "Budget", + weeklyLabel: "Usage", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Budget utilization from CheapestInference API", + toggleTitle: "Show CheapestInference usage", + cliName: "cheapestinference", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://cheapestinference.com/dashboard", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .cheapestinference, + iconResourceName: "ProviderIcon-cheapestinference", + color: ProviderColor(red: 16 / 255, green: 185 / 255, blue: 129 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "CheapestInference cost summary is not yet supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [CheapestInferenceAPIFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "cheapestinference", + aliases: ["ci"], + versionDetector: nil)) + } +} + +struct CheapestInferenceAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "cheapestinference.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw CheapestInferenceSettingsError.missingToken + } + let usage = try await CheapestInferenceUsageFetcher.fetchUsage( + apiKey: apiKey, + environment: context.env) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.cheapestInferenceToken(environment: environment) + } +} + +/// Errors related to CheapestInference settings +public enum CheapestInferenceSettingsError: LocalizedError, Sendable { + case missingToken + + public var errorDescription: String? { + switch self { + case .missingToken: + "CheapestInference API token not configured. Set CHEAPESTINFERENCE_API_KEY environment variable or configure in Settings." + } + } +} diff --git a/Sources/CodexBarCore/Providers/CheapestInference/CheapestInferenceSettingsReader.swift b/Sources/CodexBarCore/Providers/CheapestInference/CheapestInferenceSettingsReader.swift new file mode 100644 index 000000000..85ab68047 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CheapestInference/CheapestInferenceSettingsReader.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Reads CheapestInference settings from environment variables +public enum CheapestInferenceSettingsReader { + /// Environment variable key for CheapestInference API token + public static let envKey = "CHEAPESTINFERENCE_API_KEY" + + /// Returns the API token from environment if present and non-empty + public static func apiToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.envKey]) + } + + /// Returns the API URL, defaulting to production endpoint + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = environment["CHEAPESTINFERENCE_API_URL"], + let url = URL(string: cleaned(override) ?? "") + { + return url + } + return URL(string: "https://api.cheapestinference.com")! + } + + 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.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/CheapestInference/CheapestInferenceUsageStats.swift b/Sources/CodexBarCore/Providers/CheapestInference/CheapestInferenceUsageStats.swift new file mode 100644 index 000000000..520aafbf3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CheapestInference/CheapestInferenceUsageStats.swift @@ -0,0 +1,244 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// CheapestInference /v1/usage API response +public struct CheapestInferenceAPIResponse: Decodable, Sendable { + public let success: Bool + public let data: CheapestInferenceUsageData +} + +/// CheapestInference usage data from /v1/usage +public struct CheapestInferenceUsageData: Decodable, Sendable { + public let budget: CheapestInferenceBudget + public let rateLimits: CheapestInferenceRateLimits + public let plan: CheapestInferencePlan + public let credits: CheapestInferenceCredits + public let key: CheapestInferenceKeyInfo + + private enum CodingKeys: String, CodingKey { + case budget + case rateLimits = "rate_limits" + case plan + case credits + case key + } +} + +public struct CheapestInferenceBudget: Decodable, Sendable { + public let spent: Double + public let limit: Double? + public let duration: String? + public let resetsAt: String? + + private enum CodingKeys: String, CodingKey { + case spent + case limit + case duration + case resetsAt = "resets_at" + } +} + +public struct CheapestInferenceRateLimits: Decodable, Sendable { + public let rpm: Int? + public let tpm: Int? +} + +public struct CheapestInferencePlan: Decodable, Sendable { + public let slug: String? + public let status: String? + public let expiresAt: String? + + private enum CodingKeys: String, CodingKey { + case slug + case status + case expiresAt = "expires_at" + } +} + +public struct CheapestInferenceCredits: Decodable, Sendable { + public let balance: Double +} + +public struct CheapestInferenceKeyInfo: Decodable, Sendable { + public let name: String + public let type: String + public let createdAt: String + + private enum CodingKeys: String, CodingKey { + case name + case type + case createdAt = "created_at" + } +} + +/// Complete CheapestInference usage snapshot +public struct CheapestInferenceUsageSnapshot: Codable, Sendable { + public let spent: Double + public let limit: Double? + public let duration: String? + public let resetsAt: Date? + public let planSlug: String? + public let planStatus: String? + public let creditBalance: Double + public let rpm: Int? + public let tpm: Int? + public let updatedAt: Date + + /// Budget utilization as 0-100 percentage + public var usedPercent: Double { + guard let limit, limit > 0 else { return 0 } + return min(100, (self.spent / limit) * 100) + } + + /// Returns true if this snapshot contains valid budget data + public var hasBudget: Bool { + self.limit != nil && self.limit! > 0 + } +} + +extension CheapestInferenceUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let primary: RateWindow? = if self.hasBudget { + RateWindow( + usedPercent: self.usedPercent, + windowMinutes: self.windowMinutes, + resetsAt: self.resetsAt, + resetDescription: nil) + } else { + nil + } + + // Show plan and credit balance in identity + let planStr = self.planSlug?.capitalized ?? "Unknown" + let balanceStr = String(format: "$%.2f", self.creditBalance) + let identity = ProviderIdentitySnapshot( + providerID: .cheapestinference, + accountEmail: nil, + accountOrganization: "\(planStr) plan", + loginMethod: "Credits: \(balanceStr)") + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + /// Parse duration string (e.g. "5h", "30d", "1h") to minutes + private var windowMinutes: Int? { + guard let duration else { return nil } + let scanner = Scanner(string: duration) + guard let value = scanner.scanInt() else { return nil } + let unit = String(duration.dropFirst(String(value).count)) + switch unit { + case "m": return value + case "h": return value * 60 + case "d": return value * 60 * 24 + default: return nil + } + } +} + +/// Fetches usage stats from the CheapestInference API +public struct CheapestInferenceUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.cheapestInferenceUsage) + private static let requestTimeoutSeconds: TimeInterval = 15 + + /// Fetches usage from CheapestInference using the provided API key + public static func fetchUsage( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws + -> CheapestInferenceUsageSnapshot + { + guard !apiKey.isEmpty else { + throw CheapestInferenceUsageError.invalidCredentials + } + + let baseURL = CheapestInferenceSettingsReader.apiURL(environment: environment) + let usageURL = baseURL.appendingPathComponent("v1/usage") + + var request = URLRequest(url: usageURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.requestTimeoutSeconds + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw CheapestInferenceUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + Self.log.error("CheapestInference API returned \(httpResponse.statusCode)") + if httpResponse.statusCode == 401 { + throw CheapestInferenceUsageError.invalidCredentials + } + throw CheapestInferenceUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + + do { + let decoder = JSONDecoder() + let apiResponse = try decoder.decode(CheapestInferenceAPIResponse.self, from: data) + let d = apiResponse.data + + // Parse resets_at ISO8601 date + var resetsAt: Date? + if let resetsAtStr = d.budget.resetsAt { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + resetsAt = formatter.date(from: resetsAtStr) + if resetsAt == nil { + // Try without fractional seconds + formatter.formatOptions = [.withInternetDateTime] + resetsAt = formatter.date(from: resetsAtStr) + } + } + + return CheapestInferenceUsageSnapshot( + spent: d.budget.spent, + limit: d.budget.limit, + duration: d.budget.duration, + resetsAt: resetsAt, + planSlug: d.plan.slug, + planStatus: d.plan.status, + creditBalance: d.credits.balance, + rpm: d.rateLimits.rpm, + tpm: d.rateLimits.tpm, + updatedAt: Date()) + } catch let error as DecodingError { + Self.log.error("CheapestInference JSON decoding error: \(error.localizedDescription)") + throw CheapestInferenceUsageError.parseFailed(error.localizedDescription) + } catch let error as CheapestInferenceUsageError { + throw error + } catch { + Self.log.error("CheapestInference parsing error: \(error.localizedDescription)") + throw CheapestInferenceUsageError.parseFailed(error.localizedDescription) + } + } +} + +/// Errors that can occur during CheapestInference usage fetching +public enum CheapestInferenceUsageError: LocalizedError, Sendable { + case invalidCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "Invalid CheapestInference API credentials" + case let .networkError(message): + "CheapestInference network error: \(message)" + case let .apiError(message): + "CheapestInference API error: \(message)" + case let .parseFailed(message): + "Failed to parse CheapestInference response: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index ada9fac8d..2d04df871 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -61,6 +61,12 @@ public enum ProviderTokenResolver { self.openRouterResolution(environment: environment)?.token } + public static func cheapestInferenceToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cheapestInferenceResolution(environment: environment)?.token + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -141,6 +147,12 @@ public enum ProviderTokenResolver { self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment)) } + public static func cheapestInferenceResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(CheapestInferenceSettingsReader.apiToken(environment: environment)) + } + private static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index f48eefe43..bc85c62ad 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -25,6 +25,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case synthetic case warp case openrouter + case cheapestinference } // swiftformat:enable sortDeclarations @@ -52,6 +53,7 @@ public enum IconStyle: Sendable, CaseIterable { case synthetic case warp case openrouter + case cheapestinference case combined } diff --git a/docs/providers.md b/docs/providers.md index 99da2542d..4ad1691bd 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, CheapestInference)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -172,4 +172,11 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: `https://status.openrouter.ai` (link only, no auto-polling yet). - Details: `docs/openrouter.md`. +## CheapestInference +- API token from `~/.codexbar/config.json` (`providerConfig.cheapestinference.apiKey`) or `CHEAPESTINFERENCE_API_KEY` env var. +- Usage endpoint: `https://api.cheapestinference.com/v1/usage` (returns budget utilization, rate limits, plan info, credit balance). +- Same `sk-` key used for both inference and usage monitoring. +- Override base URL with `CHEAPESTINFERENCE_API_URL` env var. +- Dashboard: `https://cheapestinference.com/dashboard`. + See also: `docs/provider.md` for architecture notes.