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/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/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..45e37057f2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift @@ -0,0 +1,238 @@ +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: nil) + } + let secondary = self.weekly.map { window in + RateWindow( + usedPercent: window.usedPercent, + windowMinutes: 7 * 24 * 60, + resetsAt: window.resetsAt, + resetDescription: nil) + } + 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) + } +} + +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? + { + 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 + } + let resetText = self.capture( + pattern: #"]*>\s*Resets on ([^<]+?)\s*
"#, + in: windowBody) + return SakanaUsageSnapshot.QuotaWindow( + usedPercent: min(100, max(0, percent)), + 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..]*>\s*(?:5-hour|Weekly)\s*
|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)) + } + + @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? { + 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 = """ +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
+