Files
2026-07-13 12:22:33 +08:00

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)
}
}