From 706bb328d27d39fce610608a56a549ee5331a0e6 Mon Sep 17 00:00:00 2001 From: thomwebb Date: Tue, 19 May 2026 22:59:29 -0700 Subject: [PATCH] feat(providers): add Wafer Pass (wafer.ai) provider integration support - Integrate Wafer as a new usage provider with registration via descriptor, implementation, and token resolver patterns - Support fetching Wafer quota usage from the Wafer Pass API and mapping the response to a UsageSnapshot - Add localized strings (Catalan, English, Portuguese, Simplified Chinese, Spanish) for Wafer pass subscription status in menu bar settings - Add Wafer icon SVG and corresponding UI colors/names in widget views - Handle Wafer balance display in menu bar status item and usage menu card, including expired and quota-based states - Register Wafer in shared providers, providers enums, usage store debug logging costs, and widget provider - Add comprehensive Wafer tests covering settings reader, snapshot conversion, API fetch success, and error paths --- Sources/CodexBar/MenuCardView.swift | 14 +- .../ProviderImplementationRegistry.swift | 1 + .../Wafer/WaferProviderImplementation.swift | 29 ++++ .../CodexBar/Resources/ProviderIcon-wafer.svg | 3 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../StatusItemController+Animation.swift | 20 +++ Sources/CodexBar/UsageStore.swift | 4 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderTokenResolver.swift | 12 ++ .../CodexBarCore/Providers/Providers.swift | 2 + .../Wafer/WaferProviderDescriptor.swift | 70 +++++++++ .../Providers/Wafer/WaferSettingsReader.swift | 34 +++++ .../Providers/Wafer/WaferUsageFetcher.swift | 104 +++++++++++++ .../Providers/Wafer/WaferUsageSnapshot.swift | 54 +++++++ .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../ProviderIconResourcesTests.swift | 3 +- .../StatusItemBalanceDisplayTests.swift | 41 +++++ Tests/CodexBarTests/WaferTests.swift | 141 ++++++++++++++++++ 26 files changed, 536 insertions(+), 11 deletions(-) create mode 100644 Sources/CodexBar/Providers/Wafer/WaferProviderImplementation.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-wafer.svg create mode 100644 Sources/CodexBarCore/Providers/Wafer/WaferProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Wafer/WaferSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Wafer/WaferUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/Wafer/WaferUsageSnapshot.swift create mode 100644 Tests/CodexBarTests/WaferTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 2f1b993b9..793e7fe48 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1186,15 +1186,15 @@ extension UsageMenuCardView.Model { { primaryResetText = openRouterQuotaDetail } - if input.provider == .copilot, - let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty + if input.provider == .copilot || input.provider == .wafer, + let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { primaryDetailLeft = detail } - if input.provider == .warp || input.provider == .kilo || input.provider == .mimo || input.provider == .deepseek, - let detail = primary.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if input.provider == .warp || input.provider == .kilo || input.provider == .mimo || input + .provider == .deepseek, + let detail = primary.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { primaryDetailText = detail } @@ -1265,7 +1265,7 @@ extension UsageMenuCardView.Model { primaryPacePercent = regen.pace.pacePercent primaryPaceOnTop = regen.pace.paceOnTop } - let primaryStatusText = input.provider == .deepseek ? primaryDetailText : nil + let primaryStatusText = (input.provider == .deepseek) ? primaryDetailText : nil if input.provider == .deepseek { primaryDetailText = nil } diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 54fc82c6e..9822a1719 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -59,6 +59,7 @@ enum ProviderImplementationRegistry { case .groq: GroqProviderImplementation() case .llmproxy: LLMProxyProviderImplementation() case .deepgram: DeepgramProviderImplementation() + case .wafer: WaferProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/Wafer/WaferProviderImplementation.swift b/Sources/CodexBar/Providers/Wafer/WaferProviderImplementation.swift new file mode 100644 index 000000000..ef6586a27 --- /dev/null +++ b/Sources/CodexBar/Providers/Wafer/WaferProviderImplementation.swift @@ -0,0 +1,29 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct WaferProviderImplementation: ProviderImplementation { + let id: UsageProvider = .wafer + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_: SettingsStore) {} + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if WaferSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.tokenAccounts(for: .wafer).isEmpty + } + + @MainActor + func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [] + } +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-wafer.svg b/Sources/CodexBar/Resources/ProviderIcon-wafer.svg new file mode 100644 index 000000000..599c426ec --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-wafer.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index c0c64d9a9..55c95b84a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -462,6 +462,7 @@ "menu_bar_metric_subtitle_moonshot" = "Mostra el saldo de l'API de Moonshot / Kimi a la barra de menús."; "menu_bar_metric_subtitle_mistral" = "Mostra la despesa de l'API de Mistral del mes actual a la barra de menús."; "menu_bar_metric_subtitle_kimik2" = "Mostra els crèdits de la clau d'API de Kimi K2 a la barra de menús."; +"menu_bar_metric_subtitle_wafer" = "Mostra l'estat de la subscripció a Wafer Pass a la barra de menús."; "automatic" = "Automàtic"; "primary_api_key_limit" = "Principal (límit de la clau d'API)"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 2ce104e36..2aafadad4 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -459,6 +459,7 @@ "menu_bar_metric_title" = "Menu bar metric"; "menu_bar_metric_subtitle" = "Choose which window drives the menu bar percent."; "menu_bar_metric_subtitle_deepseek" = "Shows the DeepSeek balance in the menu bar."; +"menu_bar_metric_subtitle_wafer" = "Shows the Wafer Pass subscription status in the menu bar."; "menu_bar_metric_subtitle_moonshot" = "Shows the Moonshot / Kimi API balance in the menu bar."; "menu_bar_metric_subtitle_mistral" = "Shows current-month Mistral API spend in the menu bar."; "menu_bar_metric_subtitle_kimik2" = "Shows Kimi K2 API-key credits in the menu bar."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 01f7c8cb5..47166861f 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -462,6 +462,7 @@ "menu_bar_metric_subtitle_moonshot" = "Muestra el saldo de la API de Moonshot / Kimi en la barra de menús."; "menu_bar_metric_subtitle_mistral" = "Muestra el gasto de la API de Mistral del mes actual en la barra de menús."; "menu_bar_metric_subtitle_kimik2" = "Muestra los créditos de la clave de API de Kimi K2 en la barra de menús."; +"menu_bar_metric_subtitle_wafer" = "Muestra el estado de la suscripción a Wafer Pass en la barra de menús."; "automatic" = "Automático"; "primary_api_key_limit" = "Principal (límite de la clave de API)"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 0b93ace52..fedd86640 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -459,6 +459,7 @@ "menu_bar_metric_title" = "Métrica da barra de menus"; "menu_bar_metric_subtitle" = "Escolha qual janela define a porcentagem da barra de menus."; "menu_bar_metric_subtitle_deepseek" = "Mostra o saldo do DeepSeek na barra de menus."; +"menu_bar_metric_subtitle_wafer" = "Mostra o status da assinatura do Wafer Pass na barra de menus."; "menu_bar_metric_subtitle_moonshot" = "Mostra o saldo da API Moonshot / Kimi na barra de menus."; "menu_bar_metric_subtitle_mistral" = "Mostra o gasto da API Mistral no mês atual na barra de menus."; "menu_bar_metric_subtitle_kimik2" = "Mostra os créditos da chave de API do Kimi K2 na barra de menus."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index f489d80f8..0b65b0d14 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -466,6 +466,7 @@ "menu_bar_metric_title" = "菜单栏指标"; "menu_bar_metric_subtitle" = "选择哪个窗口驱动菜单栏百分比。"; "menu_bar_metric_subtitle_deepseek" = "在菜单栏显示 DeepSeek 余额。"; +"menu_bar_metric_subtitle_wafer" = "在菜单栏显示 Wafer Pass 订阅状态。"; "menu_bar_metric_subtitle_moonshot" = "在菜单栏显示 Moonshot / Kimi API 余额。"; "menu_bar_metric_subtitle_mistral" = "在菜单栏显示 Mistral API 本月支出。"; "menu_bar_metric_subtitle_kimik2" = "在菜单栏显示 Kimi K2 API Key 额度。"; diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 0e31ac0ee..ce2f7b8d1 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -617,6 +617,26 @@ extension StatusItemController { { return balance } + if provider == .wafer { + guard let snapshot, let primary = snapshot.primary else { + return nil + } + if primary.usedPercent >= 100.0 { + return "Expired" + } + if let desc = primary.resetDescription, + let firstPart = desc.split(separator: " ").first, + let slashIdx = firstPart.firstIndex(of: "/"), + let countStr = firstPart[.. String? + { + self.waferResolution(environment: environment)?.token + } + public static func crofToken( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { @@ -157,6 +163,12 @@ public enum ProviderTokenResolver { self.resolveEnv(DeepSeekSettingsReader.apiKey(environment: environment)) } + public static func waferResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(WaferSettingsReader.apiKey(environment: environment)) + } + public static func crofResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 3bac0341b..d43a8c51a 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -49,6 +49,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case groq case llmproxy case deepgram + case wafer } // swiftformat:enable sortDeclarations @@ -99,6 +100,7 @@ public enum IconStyle: Sendable, CaseIterable { case groq case llmproxy case deepgram + case wafer case combined } diff --git a/Sources/CodexBarCore/Providers/Wafer/WaferProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Wafer/WaferProviderDescriptor.swift new file mode 100644 index 000000000..d999f58cb --- /dev/null +++ b/Sources/CodexBarCore/Providers/Wafer/WaferProviderDescriptor.swift @@ -0,0 +1,70 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum WaferProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .wafer, + metadata: ProviderMetadata( + id: .wafer, + displayName: "Wafer", + sessionLabel: "Status", + weeklyLabel: "Status", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Wafer status", + cliName: "wafer", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://wafer.ai/pass", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .wafer, + iconResourceName: "ProviderIcon-wafer", + color: ProviderColor(red: 0.43, green: 0.16, blue: 0.85)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Wafer cost history is not tracked via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WaferAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "wafer", + aliases: [], + versionDetector: nil)) + } +} + +struct WaferAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "wafer.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 WaferUsageError.missingCredentials + } + let usage = try await WaferUsageFetcher.fetchUsage(apiKey: apiKey) + 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.waferToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Wafer/WaferSettingsReader.swift b/Sources/CodexBarCore/Providers/Wafer/WaferSettingsReader.swift new file mode 100644 index 000000000..02e2f8f1b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Wafer/WaferSettingsReader.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct WaferSettingsReader: Sendable { + public static let apiKeyEnvironmentKey = "WAFER_API_KEY" + public static let apiKeyEnvironmentKeys = [Self.apiKeyEnvironmentKey, "WAFER_KEY"] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Wafer/WaferUsageFetcher.swift b/Sources/CodexBarCore/Providers/Wafer/WaferUsageFetcher.swift new file mode 100644 index 000000000..202aebc2a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Wafer/WaferUsageFetcher.swift @@ -0,0 +1,104 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum WaferUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Wafer API key." + case let .networkError(message): + "Wafer network error: \(message)" + case let .apiError(message): + "Wafer API error: \(message)" + case let .parseFailed(message): + "Failed to parse Wafer response: \(message)" + } + } +} + +private struct WaferQuotaResponse: Decodable { + let included_request_limit: Int + let included_request_count: Int + let remaining_included_requests: Int + let seconds_to_window_end: Int + let current_period_used_percent: Double + let window_start: String + let window_end: String +} + +public struct WaferUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.waferUsage) + private static let quotaURL = URL(string: "https://pass.wafer.ai/v1/inference/quota")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> WaferUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw WaferUsageError.missingCredentials + } + + var request = URLRequest(url: self.quotaURL) + request.httpMethod = "GET" + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw WaferUsageError.networkError(error.localizedDescription) + } + + guard response.statusCode == 200 else { + let body = String(data: response.data, encoding: .utf8) ?? "" + self.log.error("Wafer API returned \(response.statusCode): \(body)") + throw WaferUsageError.apiError("HTTP \(response.statusCode)") + } + + let quota: WaferQuotaResponse + do { + quota = try JSONDecoder().decode(WaferQuotaResponse.self, from: response.data) + } catch { + self.log.error("Failed to parse Wafer response: \(error.localizedDescription)") + throw WaferUsageError.parseFailed(error.localizedDescription) + } + + var windowMinutes = 300 // fallback to 5 hours + if let startDate = parseISO8601Date(quota.window_start), + let endDate = parseISO8601Date(quota.window_end) + { + let durationSeconds = endDate.timeIntervalSince(startDate) + windowMinutes = Int(round(durationSeconds / 60.0)) + } + + return WaferUsageSnapshot( + limit: quota.included_request_limit, + count: quota.included_request_count, + remaining: quota.remaining_included_requests, + secondsToReset: quota.seconds_to_window_end, + usedPercent: quota.current_period_used_percent, + windowMinutes: windowMinutes, + updatedAt: Date()) + } + + private static func parseISO8601Date(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: string) { + return date + } + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractionalFormatter.date(from: string) + } +} diff --git a/Sources/CodexBarCore/Providers/Wafer/WaferUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Wafer/WaferUsageSnapshot.swift new file mode 100644 index 000000000..0f365f354 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Wafer/WaferUsageSnapshot.swift @@ -0,0 +1,54 @@ +import Foundation + +public struct WaferUsageSnapshot: Sendable { + public let limit: Int + public let count: Int + public let remaining: Int + public let secondsToReset: Int + public let usedPercent: Double + public let windowMinutes: Int + public let updatedAt: Date + + public init( + limit: Int, + count: Int, + remaining: Int, + secondsToReset: Int, + usedPercent: Double, + windowMinutes: Int, + updatedAt: Date) + { + self.limit = limit + self.count = count + self.remaining = remaining + self.secondsToReset = secondsToReset + self.usedPercent = usedPercent + self.windowMinutes = windowMinutes + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let resetsAt = self.updatedAt.addingTimeInterval(TimeInterval(self.secondsToReset)) + let resetDescription = "\(self.count)/\(self.limit) requests" + + let identity = ProviderIdentitySnapshot( + providerID: .wafer, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Wafer Pass") + + let primaryWindow = RateWindow( + usedPercent: self.usedPercent, + windowMinutes: self.windowMinutes, + resetsAt: resetsAt, + resetDescription: resetDescription) + + return UsageSnapshot( + primary: primaryWindow, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 22b7bb046..99739ea29 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -371,7 +371,7 @@ enum CostUsageScanner { .copilot, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, .groq, .llmproxy, - .deepgram: + .deepgram, .wafer: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 60b8d14fe..ef777464c 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -97,6 +97,7 @@ enum ProviderChoice: String, AppEnum { case .groq: return nil // Groq not yet supported in widgets case .llmproxy: return nil // LLM Proxy not yet supported in widgets case .deepgram: return nil // Deepgram not yet supported in widgets + case .wafer: return nil // Wafer not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 2cba949f1..c16db920b 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -303,6 +303,7 @@ private struct ProviderSwitchChip: View { case .groq: "Groq" case .llmproxy: "LLM Proxy" case .deepgram: "Deepgram" + case .wafer: "Wafer" } } } @@ -704,6 +705,8 @@ enum WidgetColors { Color(red: 36 / 255, green: 180 / 255, blue: 126 / 255) case .deepgram: Color(red: 10 / 255, green: 18 / 255, blue: 27 / 255) + case .wafer: + Color(red: 0.43, green: 0.16, blue: 0.85) } } } diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index abd65ecf7..85dcf8474 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -5,7 +5,7 @@ import Testing @MainActor struct ProviderIconResourcesTests { @Test - func `provider icon SV gs exist`() throws { + func `provider icon SVGs exist`() throws { let root = try Self.repoRoot() let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) @@ -30,6 +30,7 @@ struct ProviderIconResourcesTests { "groq", "llmproxy", "deepgram", + "wafer", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index 1f88a7230..fbbf64b1b 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -146,6 +146,47 @@ struct StatusItemBalanceDisplayTests { #expect(displayText == "1234.5") } + @Test + func `menu bar display text formats wafer requests remaining`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-wafer-requests", + provider: .wafer) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 12.3, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "123/1000 requests"), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .wafer) + store._setErrorForTesting(nil, provider: .wafer) + + let displayText = controller.menuBarDisplayText(for: .wafer, snapshot: snapshot) + + #expect(displayText == "877") + } + + @Test + func `menu bar display text returns nil when wafer snapshot or primary is nil`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-wafer-nil", + provider: .wafer) + let (store, controller) = self.makeStoreAndController(settings: settings) + + let displayText1 = controller.menuBarDisplayText(for: .wafer, snapshot: nil) + #expect(displayText1 == nil) + + let snapshotWithNoPrimary = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date()) + let displayText2 = controller.menuBarDisplayText(for: .wafer, snapshot: snapshotWithNoPrimary) + #expect(displayText2 == nil) + } + @Test func `kiro menu bar automatic uses credits left`() { let settings = self.makeSettings( diff --git a/Tests/CodexBarTests/WaferTests.swift b/Tests/CodexBarTests/WaferTests.swift new file mode 100644 index 000000000..6a0ce5572 --- /dev/null +++ b/Tests/CodexBarTests/WaferTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct WaferTests { + @Test + func `settings reader resolves API key from environment`() { + let envWithWaferApiKey = [ + "WAFER_API_KEY": "wfr_test_12345", + ] + let envWithWaferKey = [ + "WAFER_KEY": "wfr_test_67890", + ] + let envWithBoth = [ + "WAFER_API_KEY": "wfr_test_primary", + "WAFER_KEY": "wfr_test_fallback", + ] + let envEmpty = [String: String]() + + #expect(WaferSettingsReader.apiKey(environment: envWithWaferApiKey) == "wfr_test_12345") + #expect(WaferSettingsReader.apiKey(environment: envWithWaferKey) == "wfr_test_67890") + #expect(WaferSettingsReader.apiKey(environment: envWithBoth) == "wfr_test_primary") + #expect(WaferSettingsReader.apiKey(environment: envEmpty) == nil) + } + + @Test + func `settings reader cleans quotes from api key`() { + let envQuotes = [ + "WAFER_API_KEY": "\"wfr_quoted_key\"", + ] + let envSingleQuotes = [ + "WAFER_API_KEY": "'wfr_single_quoted_key'", + ] + #expect(WaferSettingsReader.apiKey(environment: envQuotes) == "wfr_quoted_key") + #expect(WaferSettingsReader.apiKey(environment: envSingleQuotes) == "wfr_single_quoted_key") + } + + @Test + func `snapshot builds correct UsageSnapshot when active`() { + let snapshot = WaferUsageSnapshot( + limit: 1000, + count: 123, + remaining: 877, + secondsToReset: 3600, + usedPercent: 12.3, + windowMinutes: 300, + updatedAt: Date()) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 12.3) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetDescription == "123/1000 requests") + #expect(usage.identity?.loginMethod == "Wafer Pass") + #expect(usage.identity?.providerID == .wafer) + #expect(usage.secondary == nil) + } + + @Test + func `snapshot builds correct UsageSnapshot when inactive`() { + let snapshot = WaferUsageSnapshot( + limit: 1000, + count: 1000, + remaining: 0, + secondsToReset: 7200, + usedPercent: 100.0, + windowMinutes: 240, // 4-hour window + updatedAt: Date()) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100.0) + #expect(usage.primary?.windowMinutes == 240) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(usage.identity?.loginMethod == "Wafer Pass") + } + + @Test + func `usage fetcher handles active valid API key successfully`() async throws { + let quotaJSON = """ + { + "included_request_limit": 1000, + "included_request_count": 1, + "remaining_included_requests": 999, + "seconds_to_window_end": 16449, + "current_period_used_percent": 0.1, + "window_start": "2026-05-20T00:00:00+00:00", + "window_end": "2026-05-20T05:00:00+00:00" + } + """ + + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://pass.wafer.ai/v1/inference/quota") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer wfr_active") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(quotaJSON.utf8), response) + } + + let fetcher = try await WaferUsageFetcher.fetchUsage(apiKey: "wfr_active", session: transport) + #expect(fetcher.limit == 1000) + #expect(fetcher.count == 1) + #expect(fetcher.remaining == 999) + #expect(fetcher.secondsToReset == 16449) + #expect(fetcher.usedPercent == 0.1) + #expect(fetcher.windowMinutes == 300) // 5 hours dynamically resolved + } + + @Test + func `usage fetcher handles invalid API key and throws error`() async { + let transport = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil)! + return (Data("{\"error\": \"Unauthorized\"}".utf8), response) + } + + await #expect(throws: WaferUsageError.self) { + _ = try await WaferUsageFetcher.fetchUsage(apiKey: "wfr_invalid", session: transport) + } + } + + @Test + func `usage fetcher handles malformed JSON response and throws error`() async { + let transport = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data("{\"bad_key\": 123}".utf8), response) + } + + await #expect(throws: WaferUsageError.self) { + _ = try await WaferUsageFetcher.fetchUsage(apiKey: "wfr_active", session: transport) + } + } +}