296 lines
12 KiB
Swift
296 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|