From 3585d1b2729a254be0841df02488237f8e65f13d Mon Sep 17 00:00:00 2001 From: LeoLin Date: Tue, 23 Jun 2026 23:17:48 +0800 Subject: [PATCH 1/5] Add Sakana AI usage provider --- .../Sakana/SakanaProviderImplementation.swift | 51 ++++ .../Sakana/SakanaSettingsStore.swift | 14 ++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-sakana.svg | 5 + Sources/CodexBar/UsageStore.swift | 5 +- .../Config/ProviderConfigEnvironment.swift | 14 ++ .../Generated/CodexParserHash.generated.swift | 2 +- .../Providers/ProviderDescriptor.swift | 1 + .../CodexBarCore/Providers/Providers.swift | 2 + .../Sakana/SakanaProviderDescriptor.swift | 66 ++++++ .../Sakana/SakanaSettingsReader.swift | 22 ++ .../Providers/Sakana/SakanaUsageFetcher.swift | 218 ++++++++++++++++++ .../Vendored/CostUsage/CostUsageScanner.swift | 6 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../ProviderConfigEnvironmentTests.swift | 12 + .../SakanaUsageFetcherTests.swift | 113 +++++++++ docs/configuration.md | 2 +- 18 files changed, 531 insertions(+), 7 deletions(-) create mode 100644 Sources/CodexBar/Providers/Sakana/SakanaProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Sakana/SakanaSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-sakana.svg create mode 100644 Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Sakana/SakanaSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift create mode 100644 Tests/CodexBarTests/SakanaUsageFetcherTests.swift diff --git a/Sources/CodexBar/Providers/Sakana/SakanaProviderImplementation.swift b/Sources/CodexBar/Providers/Sakana/SakanaProviderImplementation.swift new file mode 100644 index 0000000000..d58600dec9 --- /dev/null +++ b/Sources/CodexBar/Providers/Sakana/SakanaProviderImplementation.swift @@ -0,0 +1,51 @@ +import AppKit +import CodexBarCore +import Foundation + +struct SakanaProviderImplementation: ProviderImplementation { + let id: UsageProvider = .sakana + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.sakanaCookieHeader + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + SakanaSettingsReader.cookieHeader(environment: context.environment) != nil + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + let subtitle = "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." + + return [ + ProviderSettingsFieldDescriptor( + id: "sakana-cookie", + title: "Cookie header", + subtitle: subtitle, + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.sakanaCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "sakana-open-dashboard", + title: "Open Sakana AI Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://console.sakana.ai/billing") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Sakana/SakanaSettingsStore.swift b/Sources/CodexBar/Providers/Sakana/SakanaSettingsStore.swift new file mode 100644 index 0000000000..805c76ced9 --- /dev/null +++ b/Sources/CodexBar/Providers/Sakana/SakanaSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var sakanaCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .sakana)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .sakana) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .sakana, field: "cookieHeader", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 098e69325e..f3b8237451 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -50,6 +50,7 @@ enum ProviderImplementationRegistry { case .perplexity: PerplexityProviderImplementation() case .mimo: MiMoProviderImplementation() case .doubao: DoubaoProviderImplementation() + case .sakana: SakanaProviderImplementation() case .abacus: AbacusProviderImplementation() case .mistral: MistralProviderImplementation() case .deepseek: DeepSeekProviderImplementation() diff --git a/Sources/CodexBar/Resources/ProviderIcon-sakana.svg b/Sources/CodexBar/Resources/ProviderIcon-sakana.svg new file mode 100644 index 0000000000..5e199bb74f --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-sakana.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6c41431886..7edcaa7fd5 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -998,6 +998,7 @@ extension UsageStore { .jetbrains: "JetBrains AI debug log not yet implemented", .mimo: "Xiaomi MiMo debug log not yet implemented", .doubao: "Doubao debug log not yet implemented", + .sakana: "Sakana AI debug log not yet implemented", .venice: "Venice debug log not yet implemented", .commandcode: "Command Code debug log not yet implemented", .stepfun: "StepFun debug log not yet implemented", @@ -1085,8 +1086,8 @@ extension UsageStore { hasTokenAccount: deepSeekHasTokenAccount) case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, - .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, - .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes: + .sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, + .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index e157db62b0..f38a560ea8 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -95,6 +95,8 @@ public enum ProviderConfigEnvironment { self.applyAzureOpenAIOverrides(base: base, config: config) case .kimi: self.applyKimiOverrides(base: base, config: config) + case .sakana: + self.applySakanaOverrides(base: base, config: config) default: nil } @@ -285,6 +287,18 @@ public enum ProviderConfigEnvironment { return env } + private static func applySakanaOverrides( + base: [String: String], + config: ProviderConfig?) -> [String: String] + { + guard let config else { return base } + var env = base + if let cookieHeader = config.sanitizedCookieHeader { + env[SakanaSettingsReader.cookieHeaderKey] = cookieHeader + } + return env + } + private static func applyAzureOpenAIOverrides( base: [String: String], config: ProviderConfig?) -> [String: String] diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 4b866f2df3..98727c0f88 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 = "800a06dead603ea7" + static let value = "e59cf52c58111c43" } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 99ee47ccd1..fd38727e18 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -90,6 +90,7 @@ public enum ProviderDescriptorRegistry { .perplexity: PerplexityProviderDescriptor.descriptor, .mimo: MiMoProviderDescriptor.descriptor, .doubao: DoubaoProviderDescriptor.descriptor, + .sakana: SakanaProviderDescriptor.descriptor, .abacus: AbacusProviderDescriptor.descriptor, .mistral: MistralProviderDescriptor.descriptor, .deepseek: DeepSeekProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 005a6d2e4d..4fc5b4357a 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -40,6 +40,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case perplexity case mimo case doubao + case sakana case abacus case mistral case deepseek @@ -96,6 +97,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case perplexity case mimo case doubao + case sakana case abacus case mistral case deepseek diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift new file mode 100644 index 0000000000..bcaad858ac --- /dev/null +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift @@ -0,0 +1,66 @@ +import Foundation + +public enum SakanaProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .sakana, + metadata: ProviderMetadata( + id: .sakana, + displayName: "Sakana AI", + sessionLabel: "5-hour", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Sakana AI usage", + cliName: "sakana", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://console.sakana.ai/billing", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .sakana, + iconResourceName: "ProviderIcon-sakana", + color: ProviderColor(red: 0.16, green: 0.46, blue: 0.86)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Sakana AI cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [SakanaWebFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "sakana", + aliases: ["sakana-ai"], + versionDetector: nil)) + } +} + +struct SakanaWebFetchStrategy: ProviderFetchStrategy { + let id: String = "sakana.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + SakanaSettingsReader.cookieHeader(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let cookieHeader = SakanaSettingsReader.cookieHeader(environment: context.env) else { + throw SakanaUsageError.missingCookie + } + let usage = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + timeout: context.webTimeout) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaSettingsReader.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaSettingsReader.swift new file mode 100644 index 0000000000..321007e11d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaSettingsReader.swift @@ -0,0 +1,22 @@ +import Foundation + +public enum SakanaSettingsReader { + public static let cookieHeaderKey = "SAKANA_COOKIE" + + public static func cookieHeader(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + CookieHeaderNormalizer.normalize(self.cleaned(environment[self.cookieHeaderKey])) + } + + 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 + } +} diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift new file mode 100644 index 0000000000..9c45fb2306 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift @@ -0,0 +1,218 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct SakanaUsageSnapshot: Sendable { + public struct QuotaWindow: Sendable, Equatable { + public let usedPercent: Double + public let resetsAt: Date? + + public init(usedPercent: Double, resetsAt: Date?) { + self.usedPercent = usedPercent + self.resetsAt = resetsAt + } + } + + public let planName: String? + public let priceLabel: String? + public let fiveHour: QuotaWindow? + public let weekly: QuotaWindow? + public let updatedAt: Date + + public init( + planName: String?, + priceLabel: String?, + fiveHour: QuotaWindow?, + weekly: QuotaWindow?, + updatedAt: Date = Date()) + { + self.planName = planName + self.priceLabel = priceLabel + self.fiveHour = fiveHour + self.weekly = weekly + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let primary = self.fiveHour.map { window in + RateWindow( + usedPercent: window.usedPercent, + windowMinutes: 5 * 60, + resetsAt: window.resetsAt, + resetDescription: "\(Self.formatPercent(window.usedPercent))% used") + } + let secondary = self.weekly.map { window in + RateWindow( + usedPercent: window.usedPercent, + windowMinutes: 7 * 24 * 60, + resetsAt: window.resetsAt, + resetDescription: "\(Self.formatPercent(window.usedPercent))% used") + } + let planLabel = [self.planName, self.priceLabel] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " ") + let identity = ProviderIdentitySnapshot( + providerID: .sakana, + accountEmail: nil, + accountOrganization: nil, + loginMethod: planLabel.isEmpty ? nil : planLabel) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + private static func formatPercent(_ percent: Double) -> String { + let rounded = (percent * 100).rounded() / 100 + if rounded.rounded() == rounded { + return String(Int(rounded)) + } + return String(format: "%.2f", rounded) + } +} + +public enum SakanaUsageError: LocalizedError, Sendable, Equatable { + case missingCookie + case loginRequired + case apiError(Int, String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCookie: + "Missing Sakana cookie header (SAKANA_COOKIE)." + case .loginRequired: + "Sakana login is required." + case let .apiError(code, message): + "Sakana billing fetch failed (\(code)): \(message)" + case let .parseFailed(message): + "Failed to parse Sakana billing page: \(message)" + } + } +} + +public enum SakanaUsageFetcher { + private static let billingURL = URL(string: "https://console.sakana.ai/billing")! + + public static func fetchUsage( + cookieHeader: String, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + timeout: TimeInterval = 15, + now: Date = Date()) async throws -> SakanaUsageSnapshot + { + guard let cookieHeader = CookieHeaderNormalizer.normalize(cookieHeader) else { + throw SakanaUsageError.missingCookie + } + + var request = URLRequest(url: self.billingURL) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let response = try await transport.response(for: request) + if response.statusCode == 401 || response.statusCode == 403 { + throw SakanaUsageError.loginRequired + } + guard response.statusCode == 200 else { + let body = String(data: response.data.prefix(200), encoding: .utf8) ?? "" + throw SakanaUsageError.apiError(response.statusCode, body) + } + guard let html = String(data: response.data, encoding: .utf8), !html.isEmpty else { + throw SakanaUsageError.parseFailed("Billing page response was empty.") + } + return try self.parseBillingHTML(html, now: now) + } + + static func parseBillingHTML( + _ html: String, + now: Date = Date(), + timeZone: TimeZone = .current) throws -> SakanaUsageSnapshot + { + let fiveHour = self.parseWindow(label: "5-hour", html: html, timeZone: timeZone) + let weekly = self.parseWindow(label: "Weekly", html: html, timeZone: timeZone) + guard fiveHour != nil || weekly != nil else { + throw SakanaUsageError.parseFailed("Usage limit windows were not found.") + } + return SakanaUsageSnapshot( + planName: self.parsePlanName(html), + priceLabel: self.parsePlanPrice(html), + fiveHour: fiveHour, + weekly: weekly, + updatedAt: now) + } + + private static func parseWindow( + label: String, + html: String, + timeZone: TimeZone) -> SakanaUsageSnapshot.QuotaWindow? + { + let escaped = NSRegularExpression.escapedPattern(for: label) + let pattern = "]*>\\s*\(escaped)\\s*

\\s*" + + "]*>\\s*Resets on ([^<]+?)\\s*

[\\s\\S]*?" + + "]*>\\s*([0-9]+(?:\\.[0-9]+)?)% used\\s*

" + guard let match = self.firstMatch(pattern: pattern, in: html), + let resetText = self.capture(1, in: html, match: match), + let percentText = self.capture(2, in: html, match: match), + let percent = Double(percentText) + else { + return nil + } + return SakanaUsageSnapshot.QuotaWindow( + usedPercent: min(100, max(0, percent)), + resetsAt: self.parseResetDate(resetText, timeZone: timeZone)) + } + + private static func parsePlanName(_ html: String) -> String? { + self.capture( + pattern: #"]*data-slot="card-title"[^>]*>[\s\S]*?\s*([^<]+?)\s*"#, + in: html) + } + + private static func parsePlanPrice(_ html: String) -> String? { + let pattern = #"]*data-slot="card-title"[^>]*>[\s\S]*?[^<]+\s*"# + + #"]*>\s*([^<]+?)\s*"# + return self.capture( + pattern: pattern, + in: html) + } + + private static func parseResetDate(_ value: String, timeZone: TimeZone) -> Date? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "MMMM d, yyyy 'at' h:mm a" + return formatter.date(from: trimmed) + } + + private static func capture(pattern: String, in html: String) -> String? { + guard let match = self.firstMatch(pattern: pattern, in: html) else { return nil } + return self.capture(1, in: html, match: match) + } + + private static func firstMatch(pattern: String, in html: String) -> NSTextCheckingResult? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let range = NSRange(html.startIndex.. String? { + let range = match.range(at: index) + guard range.location != NSNotFound, + let swiftRange = Range(range, in: html) + else { + return nil + } + let value = html[swiftRange].trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index aff14a6d9a..104a1d2afa 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -450,9 +450,9 @@ enum CostUsageScanner { case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .alibabatokenplan, .factory, .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, - .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, - .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .stepfun, .bedrock, .grok, - .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes: + .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, + .abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .stepfun, + .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 923690bb13..4e35d422f1 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -90,6 +90,7 @@ enum ProviderChoice: String, AppEnum { case .perplexity: return nil // Perplexity not yet supported in widgets case .mimo: return nil // Xiaomi MiMo not yet supported in widgets case .doubao: return nil // Doubao not yet supported in widgets + case .sakana: return nil // Sakana AI not yet supported in widgets case .abacus: return nil // Abacus AI not yet supported in widgets case .mistral: return nil // Mistral not yet supported in widgets case .deepseek: return nil // DeepSeek not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 10fe0183fe..3eaa34a937 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -299,6 +299,7 @@ private struct ProviderSwitchChip: View { case .perplexity: "Pplx" case .mimo: "MiMo" case .doubao: "Doubao" + case .sakana: "Sakana" case .abacus: "Abacus" case .mistral: "Mistral" case .deepseek: "DeepSeek" @@ -825,6 +826,8 @@ enum WidgetColors { Color(red: 1.0, green: 105 / 255, blue: 0) case .doubao: Color(red: 45 / 255, green: 136 / 255, blue: 255 / 255) // Doubao blue + case .sakana: + Color(red: 41 / 255, green: 117 / 255, blue: 219 / 255) case .abacus: Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) case .mistral: diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 084a591643..112cf3424a 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -63,6 +63,18 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.doubaoToken(environment: env) == "db-token") } + @Test + func `applies cookie header override for sakana`() { + let config = ProviderConfig(id: .sakana, cookieHeader: "Cookie: session=abc") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .sakana, + config: config) + + #expect(env[SakanaSettingsReader.cookieHeaderKey] == "Cookie: session=abc") + #expect(SakanaSettingsReader.cookieHeader(environment: env) == "session=abc") + } + @Test func `applies API key override for moonshot`() { let config = ProviderConfig(id: .moonshot, apiKey: "moon-token") diff --git a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift new file mode 100644 index 0000000000..e1af9af401 --- /dev/null +++ b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift @@ -0,0 +1,113 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct SakanaUsageFetcherTests { + @Test + func `billing html maps five hour and weekly windows`() throws { + let now = Date(timeIntervalSince1970: 1_782_222_000) + let usage = try SakanaUsageFetcher.parseBillingHTML( + Self.billingHTML, + now: now, + timeZone: Self.shanghaiTimeZone).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 92) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == Self.date(year: 2026, month: 6, day: 23, hour: 22, minute: 53)) + #expect(usage.primary?.resetDescription == "92% used") + #expect(usage.secondary?.usedPercent == 32) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 8, minute: 0)) + #expect(usage.identity?.providerID == .sakana) + #expect(usage.identity?.loginMethod == "Standard $20/mo") + #expect(usage.updatedAt == now) + } + + @Test + func `fetch sends normalized cookie header to billing endpoint`() async throws { + let transport = SakanaScriptedTransport(statusCode: 200, body: Self.billingHTML) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "Cookie: session=abc; theme=dark", + session: transport, + now: Date(timeIntervalSince1970: 0)) + let request = await transport.lastCapturedRequest() + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(request?.url == "https://console.sakana.ai/billing") + #expect(request?.method == "GET") + #expect(request?.cookie == "session=abc; theme=dark") + } + + @Test + func `missing usage windows throws parse error`() { + #expect(throws: SakanaUsageError.parseFailed("Usage limit windows were not found.")) { + _ = try SakanaUsageFetcher.parseBillingHTML("
Billing
") + } + } + + private static let shanghaiTimeZone = TimeZone(identifier: "Asia/Shanghai")! + + private static func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = Self.shanghaiTimeZone + return calendar.date(from: DateComponents( + year: year, + month: month, + day: day, + hour: hour, + minute: minute)) + } + + private static let billingHTML = """ +
+
Standard$20/mo
+
Usage limit
+

5-hour

+

Resets on June 23, 2026 at 10:53 PM

+ +

92% used

+

Weekly

+

Resets on June 29, 2026 at 8:00 AM

+ +

32% used

+
+ """ +} + +private actor SakanaScriptedTransport: ProviderHTTPTransport { + struct CapturedRequest: Sendable { + let url: String? + let method: String? + let cookie: String? + } + + private let statusCode: Int + private let body: String + private var capturedRequest: CapturedRequest? + + init(statusCode: Int, body: String) { + self.statusCode = statusCode + self.body = body + } + + func lastCapturedRequest() -> CapturedRequest? { + self.capturedRequest + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.capturedRequest = CapturedRequest( + url: request.url?.absoluteString, + method: request.httpMethod, + cookie: request.value(forHTTPHeaderField: "Cookie")) + let response = HTTPURLResponse( + url: request.url!, + statusCode: self.statusCode, + httpVersion: "HTTP/1.1", + headerFields: [:])! + return (Data(self.body.utf8), response) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 6a75774965..66a9b30e10 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -167,7 +167,7 @@ and never paste real cookie values or readable DevTools screenshots into public ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`. +`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. From e9d4fd63b04ec71424aaeef91290ca419279fb75 Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 00:34:16 +0800 Subject: [PATCH 2/5] ci: rerun Sakana provider checks From 3676c209a06996c440e69abbd4f167850ac934e4 Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 14:44:24 +0800 Subject: [PATCH 3/5] fix(sakana): avoid fake reset descriptions --- .../Providers/Sakana/SakanaUsageFetcher.swift | 12 ++---------- Tests/CodexBarTests/SakanaUsageFetcherTests.swift | 14 +++++++++++++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift index 9c45fb2306..09f9a67712 100644 --- a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift @@ -40,14 +40,14 @@ public struct SakanaUsageSnapshot: Sendable { usedPercent: window.usedPercent, windowMinutes: 5 * 60, resetsAt: window.resetsAt, - resetDescription: "\(Self.formatPercent(window.usedPercent))% used") + resetDescription: nil) } let secondary = self.weekly.map { window in RateWindow( usedPercent: window.usedPercent, windowMinutes: 7 * 24 * 60, resetsAt: window.resetsAt, - resetDescription: "\(Self.formatPercent(window.usedPercent))% used") + resetDescription: nil) } let planLabel = [self.planName, self.priceLabel] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -67,14 +67,6 @@ public struct SakanaUsageSnapshot: Sendable { updatedAt: self.updatedAt, identity: identity) } - - private static func formatPercent(_ percent: Double) -> String { - let rounded = (percent * 100).rounded() / 100 - if rounded.rounded() == rounded { - return String(Int(rounded)) - } - return String(format: "%.2f", rounded) - } } public enum SakanaUsageError: LocalizedError, Sendable, Equatable { diff --git a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift index e1af9af401..2da8959bb0 100644 --- a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift @@ -17,10 +17,11 @@ struct SakanaUsageFetcherTests { #expect(usage.primary?.usedPercent == 92) #expect(usage.primary?.windowMinutes == 300) #expect(usage.primary?.resetsAt == Self.date(year: 2026, month: 6, day: 23, hour: 22, minute: 53)) - #expect(usage.primary?.resetDescription == "92% used") + #expect(usage.primary?.resetDescription == nil) #expect(usage.secondary?.usedPercent == 32) #expect(usage.secondary?.windowMinutes == 10080) #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 8, minute: 0)) + #expect(usage.secondary?.resetDescription == nil) #expect(usage.identity?.providerID == .sakana) #expect(usage.identity?.loginMethod == "Standard $20/mo") #expect(usage.updatedAt == now) @@ -49,6 +50,17 @@ struct SakanaUsageFetcherTests { } } + @Test + func `unparsed reset date does not become reset description`() throws { + let usage = try SakanaUsageFetcher.parseBillingHTML( + Self.billingHTML.replacing("June 23, 2026 at 10:53 PM", with: "soon-ish"), + timeZone: Self.shanghaiTimeZone).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 92) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == nil) + } + private static let shanghaiTimeZone = TimeZone(identifier: "Asia/Shanghai")! private static func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date? { From f23516ec71ba63bf5cb9b2e263fedbc541535047 Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 20:58:07 +0800 Subject: [PATCH 4/5] fix(sakana): parse windows without reset text --- .../Providers/Sakana/SakanaUsageFetcher.swift | 9 ++++++--- .../CodexBarTests/SakanaUsageFetcherTests.swift | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift index 09f9a67712..a65f11092e 100644 --- a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift @@ -147,18 +147,21 @@ public enum SakanaUsageFetcher { { let escaped = NSRegularExpression.escapedPattern(for: label) let pattern = "]*>\\s*\(escaped)\\s*

\\s*" - + "]*>\\s*Resets on ([^<]+?)\\s*

[\\s\\S]*?" + + "([\\s\\S]*?)" + "]*>\\s*([0-9]+(?:\\.[0-9]+)?)% used\\s*

" guard let match = self.firstMatch(pattern: pattern, in: html), - let resetText = self.capture(1, in: html, match: match), + let windowBody = self.capture(1, in: html, match: match), let percentText = self.capture(2, in: html, match: match), let percent = Double(percentText) else { return nil } + let resetText = self.capture( + pattern: #"]*>\s*Resets on ([^<]+?)\s*

"#, + in: windowBody) return SakanaUsageSnapshot.QuotaWindow( usedPercent: min(100, max(0, percent)), - resetsAt: self.parseResetDate(resetText, timeZone: timeZone)) + resetsAt: resetText.flatMap { self.parseResetDate($0, timeZone: timeZone) }) } private static func parsePlanName(_ html: String) -> String? { diff --git a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift index 2da8959bb0..38e014d497 100644 --- a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift @@ -61,6 +61,23 @@ struct SakanaUsageFetcherTests { #expect(usage.primary?.resetDescription == nil) } + @Test + func `window without reset line still maps percent`() throws { + let html = Self.billingHTML.replacing( + "

Resets on June 23, 2026 at 10:53 PM

", + with: "") + let usage = try SakanaUsageFetcher.parseBillingHTML( + html, + timeZone: Self.shanghaiTimeZone).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 92) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == nil) + #expect(usage.secondary?.usedPercent == 32) + #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 8, minute: 0)) + } + private static let shanghaiTimeZone = TimeZone(identifier: "Asia/Shanghai")! private static func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date? { From 84a1c067b6550e118474d99b8c0bbe130672168c Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 21:08:17 +0800 Subject: [PATCH 5/5] fix(sakana): harden cli and quota parsing --- Sources/CodexBarCLI/CLIUsageCommand.swift | 6 +++ .../Providers/Sakana/SakanaUsageFetcher.swift | 39 +++++++++++++++---- Tests/CodexBarTests/CLIEntryTests.swift | 12 ++++++ .../SakanaUsageFetcherTests.swift | 14 +++++++ 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index e331e09ff8..554fc087dd 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -592,6 +592,12 @@ extension CodexBarCLI { { return false } + if provider == .sakana, + sourceMode == .auto || sourceMode == .web, + environment.map({ SakanaSettingsReader.cookieHeader(environment: $0) != nil }) == true + { + return false + } if provider == .ollama, sourceMode == .auto { diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift index a65f11092e..45e37057f2 100644 --- a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift @@ -145,13 +145,10 @@ public enum SakanaUsageFetcher { html: String, timeZone: TimeZone) -> SakanaUsageSnapshot.QuotaWindow? { - let escaped = NSRegularExpression.escapedPattern(for: label) - let pattern = "]*>\\s*\(escaped)\\s*

\\s*" - + "([\\s\\S]*?)" - + "]*>\\s*([0-9]+(?:\\.[0-9]+)?)% used\\s*

" - guard let match = self.firstMatch(pattern: pattern, in: html), - let windowBody = self.capture(1, in: html, match: match), - let percentText = self.capture(2, in: html, match: match), + guard let windowBody = self.windowBody(label: label, html: html), + let percentText = self.capture( + pattern: #"]*>\s*([0-9]+(?:\.[0-9]+)?)% used\s*

"#, + in: windowBody), let percent = Double(percentText) else { return nil @@ -164,6 +161,34 @@ public enum SakanaUsageFetcher { resetsAt: resetText.flatMap { self.parseResetDate($0, timeZone: timeZone) }) } + private static func windowBody(label: String, html: String) -> String? { + let escaped = NSRegularExpression.escapedPattern(for: label) + let labelPattern = "]*>\\s*\(escaped)\\s*

" + guard let labelMatch = self.firstMatch(pattern: labelPattern, in: html), + let bodyStart = Range(labelMatch.range, in: html)?.upperBound + else { + return nil + } + + let bodyStartOffset = NSMaxRange(labelMatch.range) + let bodyEnd = self.windowBoundary(after: bodyStartOffset, in: html) ?? html.endIndex + let body = html[bodyStart.. String.Index? { + let boundaryPattern = + #"]*>\s*(?:5-hour|Weekly)\s*

|]*data-slot=(?:"card"|'card'|"card-title"|'card-title')[^>]*>"# + guard let regex = try? NSRegularExpression(pattern: boundaryPattern, options: [.caseInsensitive]) else { + return nil + } + let nsRange = NSRange(location: offset, length: max(0, (html as NSString).length - offset)) + guard let match = regex.firstMatch(in: html, options: [], range: nsRange) else { + return nil + } + return Range(match.range, in: html)?.lowerBound + } + private static func parsePlanName(_ html: String) -> String? { self.capture( pattern: #"]*data-slot="card-title"[^>]*>[\s\S]*?\s*([^<]+?)\s*"#, diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index ce845423db..7ed7b998be 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -377,6 +377,18 @@ final class CLIEntryTests: XCTestCase { commandcode: .init( cookieSource: .auto, manualCookieHeader: nil)))) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .sakana, + environment: ["SAKANA_COOKIE": "session=manual"])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .sakana, + environment: ["SAKANA_COOKIE": "session=manual"])) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .sakana, + environment: [:])) XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( .auto, provider: .opencode, diff --git a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift index 38e014d497..0ac1d26cba 100644 --- a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift @@ -78,6 +78,20 @@ struct SakanaUsageFetcherTests { #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 8, minute: 0)) } + @Test + func `missing window percent does not read next quota window`() throws { + let html = Self.billingHTML.replacing( + "

92% used

", + with: "") + let usage = try SakanaUsageFetcher.parseBillingHTML( + html, + timeZone: Self.shanghaiTimeZone).toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 32) + #expect(usage.secondary?.windowMinutes == 10080) + } + private static let shanghaiTimeZone = TimeZone(identifier: "Asia/Shanghai")! private static func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date? {