295 lines · 11.6 KB · Swift
RawDownload
import Foundation #if canImport(FoundationNetworking)import FoundationNetworking#endif public struct ManusCreditsResponse: Decodable, Sendable {    public let totalCredits: Double    public let freeCredits: Double    public let periodicCredits: Double    public let addonCredits: Double    public let refreshCredits: Double    public let maxRefreshCredits: Double    public let proMonthlyCredits: Double    public let eventCredits: Double    public let nextRefreshTime: Date?    public let refreshInterval: String?     public init(        totalCredits: Double,        freeCredits: Double,        periodicCredits: Double,        addonCredits: Double,        refreshCredits: Double,        maxRefreshCredits: Double,        proMonthlyCredits: Double,        eventCredits: Double,        nextRefreshTime: Date? = nil,        refreshInterval: String? = nil)    {        self.totalCredits = totalCredits        self.freeCredits = freeCredits        self.periodicCredits = periodicCredits        self.addonCredits = addonCredits        self.refreshCredits = refreshCredits        self.maxRefreshCredits = maxRefreshCredits        self.proMonthlyCredits = proMonthlyCredits        self.eventCredits = eventCredits        self.nextRefreshTime = nextRefreshTime        self.refreshInterval = refreshInterval    }     private enum CodingKeys: String, CodingKey {        case totalCredits        case freeCredits        case periodicCredits        case addonCredits        case refreshCredits        case maxRefreshCredits        case proMonthlyCredits        case eventCredits        case nextRefreshTime        case refreshInterval    }     public init(from decoder: any Decoder) throws {        let container = try decoder.container(keyedBy: CodingKeys.self)        self.totalCredits = container.decodeLossyDoubleIfPresent(forKey: .totalCredits) ?? 0        self.freeCredits = container.decodeLossyDoubleIfPresent(forKey: .freeCredits) ?? 0        self.periodicCredits = container.decodeLossyDoubleIfPresent(forKey: .periodicCredits) ?? 0        self.addonCredits = container.decodeLossyDoubleIfPresent(forKey: .addonCredits) ?? 0        self.refreshCredits = container.decodeLossyDoubleIfPresent(forKey: .refreshCredits) ?? 0        self.maxRefreshCredits = container.decodeLossyDoubleIfPresent(forKey: .maxRefreshCredits) ?? 0        self.proMonthlyCredits = container.decodeLossyDoubleIfPresent(forKey: .proMonthlyCredits) ?? 0        self.eventCredits = container.decodeLossyDoubleIfPresent(forKey: .eventCredits) ?? 0        self.nextRefreshTime = container.decodeIfPresentFlexibleDate(forKey: .nextRefreshTime)        self.refreshInterval = try? container.decodeIfPresent(String.self, forKey: .refreshInterval)    }} public enum ManusUsageFetcher {    private static let log = CodexBarLog.logger(LogCategories.manusAPI)    private static let creditsURL =        URL(string: "https://api.manus.im/user.v1.UserService/GetAvailableCredits")!    @TaskLocal static var fetchCreditsOverride:        (@Sendable (String, Date) async throws -> ManusCreditsResponse)?     public static func fetchCredits(        sessionToken: String,        now: Date = Date()) async throws -> ManusCreditsResponse    {        if let override = self.fetchCreditsOverride {            return try await override(sessionToken, now)        }         guard !sessionToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {            throw ManusAPIError.missingToken        }         var request = URLRequest(url: self.creditsURL)        request.httpMethod = "POST"        request.timeoutInterval = 15        request.httpBody = Data("{}".utf8)        request.setValue("application/json", forHTTPHeaderField: "Accept")        request.setValue("application/json", forHTTPHeaderField: "Content-Type")        request.setValue("Bearer \(sessionToken)", forHTTPHeaderField: "Authorization")        request.setValue("https://manus.im", forHTTPHeaderField: "Origin")        request.setValue("https://manus.im/", forHTTPHeaderField: "Referer")        request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version")        let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"        request.setValue(            userAgent,            forHTTPHeaderField: "User-Agent")         let response = try await ProviderHTTPClient.shared.response(for: request)        let data = response.data        guard response.statusCode == 200 else {            let body = String(data: data, encoding: .utf8) ?? "<binary>"            let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body            Self.log.error("Manus API returned \(response.statusCode): \(truncated)")            if response.statusCode == 401 || response.statusCode == 403 {                throw ManusAPIError.invalidToken            }            throw ManusAPIError.apiError("HTTP \(response.statusCode)")        }         do {            return try self.parseResponse(data)        } catch let error as ManusAPIError {            throw error        } catch {            let preview = String(data: data.prefix(500), encoding: .utf8) ?? "<binary>"            Self.log.error("Manus parse failed: \(error) — response: \(preview)")            throw ManusAPIError.parseFailed(error.localizedDescription)        }    }     public static func parseResponse(_ data: Data) throws -> ManusCreditsResponse {        let decoder = JSONDecoder()         // Try envelope first — the direct decoder defaults missing fields to 0,        // so it would "succeed" on wrapped payloads and silently return zero credits.        if let envelope = try? decoder.decode(ManusCreditsEnvelope.self, from: data),           let response = envelope.data ?? envelope.result ?? envelope.response ?? envelope.availableCredits        {            return response        }         let response = try decoder.decode(ManusCreditsResponse.self, from: data)        // The custom decoder defaults every numeric field to 0, so an unrelated JSON        // object (e.g. an error payload) would otherwise surface as a bogus zero-credit        // snapshot. Require at least one known credits key in the raw payload.        guard Self.payloadContainsCreditsField(data: data) else {            throw ManusAPIError.parseFailed("response missing expected credits fields")        }        return response    }     private static let expectedCreditsKeys: Set<String> = [        "totalCredits", "freeCredits", "periodicCredits", "addonCredits",        "refreshCredits", "maxRefreshCredits", "proMonthlyCredits", "eventCredits",    ]     private static func payloadContainsCreditsField(data: Data) -> Bool {        guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {            return false        }        return !Self.expectedCreditsKeys.isDisjoint(with: object.keys)    }} extension ManusCreditsResponse {    public func toUsageSnapshot(now: Date = Date()) -> UsageSnapshot {        let primary: RateWindow? = if self.proMonthlyCredits > 0 {            RateWindow(                usedPercent: min(                    100,                    max(0, (self.proMonthlyCredits - self.periodicCredits) / self.proMonthlyCredits * 100)),                windowMinutes: nil,                resetsAt: nil,                resetDescription: Self.monthlyDetail(totalCredits: self.totalCredits, freeCredits: self.freeCredits))        } else {            nil        }         let secondary: RateWindow? = if self.maxRefreshCredits > 0 {            RateWindow(                usedPercent: min(                    100,                    max(0, (self.maxRefreshCredits - self.refreshCredits) / self.maxRefreshCredits * 100)),                windowMinutes: nil,                resetsAt: self.nextRefreshTime,                resetDescription: Self.refreshDetail(                    refreshCredits: self.refreshCredits,                    maxRefreshCredits: self.maxRefreshCredits,                    refreshInterval: self.refreshInterval))        } else {            nil        }         let balance = Self.creditCountString(self.totalCredits)        let identity = ProviderIdentitySnapshot(            providerID: .manus,            accountEmail: nil,            accountOrganization: nil,            loginMethod: "Balance: \(balance) credits")         return UsageSnapshot(            primary: primary,            secondary: secondary,            tertiary: nil,            providerCost: nil,            updatedAt: now,            identity: identity)    }     private static func creditCountString(_ value: Double) -> String {        let formatter = NumberFormatter()        formatter.numberStyle = .decimal        formatter.usesGroupingSeparator = true        formatter.maximumFractionDigits = 0        formatter.locale = Locale(identifier: "en_US_POSIX")        return formatter.string(from: NSNumber(value: value.rounded())) ?? String(Int(value.rounded()))    }     private static func monthlyDetail(totalCredits: Double, freeCredits: Double) -> String? {        let total = self.creditCountString(totalCredits)        let free = self.creditCountString(freeCredits)        return "Total \(total) • Free \(free)"    }     private static func refreshDetail(        refreshCredits: Double,        maxRefreshCredits: Double,        refreshInterval: String?) -> String?    {        let refresh = self.creditCountString(refreshCredits)        let maxRefresh = self.creditCountString(maxRefreshCredits)        if let refreshInterval, !refreshInterval.isEmpty {            return "\(refreshInterval.capitalized): \(refresh) / \(maxRefresh)"        }        return "\(refresh) / \(maxRefresh)"    }} public enum ManusAPIError: LocalizedError, Equatable, Sendable {    case missingToken    case invalidCookie    case invalidToken    case networkError(String)    case apiError(String)    case parseFailed(String)     public var errorDescription: String? {        switch self {        case .missingToken:            "No Manus session token provided."        case .invalidCookie:            "Manus session cookie is invalid."        case .invalidToken:            "Invalid Manus session token."        case let .networkError(message):            "Manus network error: \(message)"        case let .apiError(message):            "Manus API error: \(message)"        case let .parseFailed(message):            "Failed to parse Manus response: \(message)"        }    }} private struct ManusCreditsEnvelope: Decodable {    let data: ManusCreditsResponse?    let result: ManusCreditsResponse?    let response: ManusCreditsResponse?    let availableCredits: ManusCreditsResponse?} extension KeyedDecodingContainer where K: CodingKey {    fileprivate func decodeLossyDoubleIfPresent(forKey key: K) -> Double? {        if let value = try? self.decodeIfPresent(Double.self, forKey: key) {            return value        }        if let intValue = try? self.decodeIfPresent(Int.self, forKey: key) {            return Double(intValue)        }        if let stringValue = try? self.decodeIfPresent(String.self, forKey: key) {            return Double(stringValue.trimmingCharacters(in: .whitespacesAndNewlines))        }        return nil    }     fileprivate func decodeIfPresentFlexibleDate(forKey key: K) -> Date? {        if let value = try? self.decodeIfPresent(Date.self, forKey: key) {            return value        }        guard let stringValue = try? self.decodeIfPresent(String.self, forKey: key),              !stringValue.isEmpty        else {            return nil        }        return ISO8601DateFormatter().date(from: stringValue)    }}