740 lines · 30.2 KB · Swift
RawDownload
import Foundation#if canImport(FoundationNetworking)import FoundationNetworking#endif // MARK: - API response types /// A flexible number type that can decode from both JSON integers and floats./// The StepFun API returns `five_hour_usage_left_rate: 1` (int) or `0.99781543` (float).public struct StepFunFlexibleNumber: Decodable, Sendable {    public let value: Double     public init(from decoder: Decoder) throws {        let container = try decoder.singleValueContainer()        if let intVal = try? container.decode(Int.self) {            self.value = Double(intVal)        } else if let doubleVal = try? container.decode(Double.self) {            self.value = doubleVal        } else if let strVal = try? container.decode(String.self),                  let parsed = Double(strVal)        {            // The API returns some numeric fields as JSON strings (e.g. "400000000").            self.value = parsed        } else {            self.value = 0        }    }     public init(_ value: Double) {        self.value = value    }} /// A flexible timestamp type that can decode from both JSON strings and integers./// The StepFun API returns timestamps as strings like `"1777528800"`.public struct StepFunFlexibleTimestamp: Decodable, Sendable {    public let value: Int64     public init(from decoder: Decoder) throws {        let container = try decoder.singleValueContainer()        if let strVal = try? container.decode(String.self), let parsed = Int64(strVal) {            self.value = parsed        } else if let intVal = try? container.decode(Int64.self) {            self.value = intVal        } else {            self.value = 0        }    }     public init(_ value: Int64) {        self.value = value    }} public struct StepFunRateLimitResponse: Decodable, Sendable {    public let status: Int?    public let code: Int?    public let message: String?    public let desc: String?    public let fiveHourUsageLeftRate: StepFunFlexibleNumber?    public let weeklyUsageLeftRate: StepFunFlexibleNumber?    public let fiveHourUsageResetTime: StepFunFlexibleTimestamp?    public let weeklyUsageResetTime: StepFunFlexibleTimestamp?    public let planFamily: StepFunFlexibleNumber?    public let planCreditRateLimit: StepFunPlanCreditRateLimit?     enum CodingKeys: String, CodingKey {        case status        case code        case message        case desc        case fiveHourUsageLeftRate = "five_hour_usage_left_rate"        case weeklyUsageLeftRate = "weekly_usage_left_rate"        case fiveHourUsageResetTime = "five_hour_usage_reset_time"        case weeklyUsageResetTime = "weekly_usage_reset_time"        case planFamily = "plan_family"        case planCreditRateLimit = "plan_credit_rate_limit"    }     public var isSuccess: Bool {        self.status == 1    }     /// Credit-based plans (plan_family=2) report usage via `plan_credit_rate_limit`    /// instead of the five-hour / weekly rate windows. Those rate fields are 0 with    /// reset_time "0" — meaning "no window configured", NOT "fully consumed".    var isCreditPlan: Bool {        // plan_family 2 = credit-based subscription plans (e.g. Mini, Pro).        if let family = self.planFamily?.value, family > 0 {            return family == 2        }        // Fallback heuristic: if both rate windows are 0 with no reset time, but        // credit data is present, treat as a credit plan.        if let credit = self.planCreditRateLimit,           (credit.subscriptionCreditLeftRate?.value ?? 0) > 0        {            let fiveHourZero = (self.fiveHourUsageLeftRate?.value ?? 1) == 0            let weeklyZero = (self.weeklyUsageLeftRate?.value ?? 1) == 0            let fiveHourNoReset = (self.fiveHourUsageResetTime?.value ?? 0) == 0            let weeklyNoReset = (self.weeklyUsageResetTime?.value ?? 0) == 0            if (fiveHourZero && fiveHourNoReset) || (weeklyZero && weeklyNoReset) {                return true            }        }        return false    }} /// The `plan_credit_rate_limit` object returned for credit-based plans.public struct StepFunPlanCreditRateLimit: Decodable, Sendable {    public let subscriptionCreditLeftRate: StepFunFlexibleNumber?    public let subscriptionCreditResetTime: StepFunFlexibleTimestamp?    public let topupCreditLeftRate: StepFunFlexibleNumber?    public let creditBuckets: [StepFunPlanCreditBucket]?     enum CodingKeys: String, CodingKey {        case subscriptionCreditLeftRate = "subscription_credit_left_rate"        case subscriptionCreditResetTime = "subscription_credit_reset_time"        case topupCreditLeftRate = "topup_credit_left_rate"        case creditBuckets = "credit_buckets"    }     /// Combined remaining fraction across subscription + top-up credits.    var totalCreditLeftRate: Double? {        // Subscription and top-up rates are independent fractions, so adding them        // does not produce a combined rate. Prefer the absolute bucket balances.        if let buckets = creditBuckets, !buckets.isEmpty {            let balances = buckets.compactMap { bucket -> (total: Double, residual: Double)? in                guard let total = bucket.creditTotal?.value,                      let residual = bucket.creditResidual?.value,                      total.isFinite,                      residual.isFinite,                      total > 0,                      residual >= 0,                      residual <= total                else { return nil }                return (total, residual)            }            if balances.count == buckets.count {                let total = balances.reduce(0.0) { $0 + $1.total }                let residual = balances.reduce(0.0) { $0 + $1.residual }                return residual / total            }        }         // Without bucket sizes there is no sound way to weight both rates. The        // subscription balance is the primary plan allowance; use top-up only        // when no subscription rate is present.        return self.subscriptionCreditLeftRate?.value ?? self.topupCreditLeftRate?.value    }} public struct StepFunPlanCreditBucket: Decodable, Sendable {    public let creditTotal: StepFunFlexibleNumber?    public let creditResidual: StepFunFlexibleNumber?    public let expireAt: StepFunFlexibleTimestamp?    public let nextResetAt: StepFunFlexibleTimestamp?     enum CodingKeys: String, CodingKey {        case creditTotal = "credit_total"        case creditResidual = "credit_residual"        case expireAt = "expire_at"        case nextResetAt = "next_reset_at"    }} // MARK: - Plan status response types struct StepFunPlanStatusResponse: Decodable {    let status: Int?    let subscription: StepFunSubscription?     var planName: String? {        self.subscription?.name?.trimmingCharacters(in: .whitespacesAndNewlines)    }} struct StepFunSubscription: Decodable {    let name: String?    let planType: Int?    let planStatus: Int?     enum CodingKeys: String, CodingKey {        case name        case planType = "plan_type"        case planStatus = "status"    }} // MARK: - Auth response types struct StepFunRegisterDeviceResponse: Decodable {    let accessToken: StepFunTokenPair?    let refreshToken: StepFunTokenPair?} struct StepFunLoginResponse: Decodable {    let accessToken: StepFunTokenPair?    let refreshToken: StepFunTokenPair?} struct StepFunRefreshTokenResponse: Decodable {    let accessToken: StepFunTokenPair?    let refreshToken: StepFunTokenPair?} struct StepFunTokenPair: Decodable {    let raw: String} // MARK: - Domain snapshot public struct StepFunUsageSnapshot: Sendable {    public let fiveHourUsageLeftRate: Double    public let weeklyUsageLeftRate: Double    public let fiveHourUsageResetTime: Date    public let weeklyUsageResetTime: Date    public let planName: String?    public let updatedAt: Date    public let creditLeftRate: Double?    public let creditResetTime: Date?    public let isCreditPlan: Bool     public init(        fiveHourUsageLeftRate: Double,        weeklyUsageLeftRate: Double,        fiveHourUsageResetTime: Date,        weeklyUsageResetTime: Date,        planName: String? = nil,        updatedAt: Date,        creditLeftRate: Double? = nil,        creditResetTime: Date? = nil,        isCreditPlan: Bool = false)    {        self.fiveHourUsageLeftRate = fiveHourUsageLeftRate        self.weeklyUsageLeftRate = weeklyUsageLeftRate        self.fiveHourUsageResetTime = fiveHourUsageResetTime        self.weeklyUsageResetTime = weeklyUsageResetTime        self.planName = planName        self.updatedAt = updatedAt        self.creditLeftRate = creditLeftRate        self.creditResetTime = creditResetTime        self.isCreditPlan = isCreditPlan    }     public func toUsageSnapshot() -> UsageSnapshot {        let trimmedPlan = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines)        let loginMethod = (trimmedPlan?.isEmpty ?? true) ? "password" : trimmedPlan         let identity = ProviderIdentitySnapshot(            providerID: .stepfun,            accountEmail: nil,            accountOrganization: nil,            loginMethod: loginMethod)         // Credit-based plans (plan_family=2) don't have 5h/weekly rate windows.        // Show the credit balance as the primary window and drop the meaningless        // 0%-left rate windows entirely.        if self.isCreditPlan, let creditRate = self.creditLeftRate {            let creditUsedPercent = max(0, min(100, (1.0 - creditRate) * 100))            let resetDate = self.creditResetTime ?? Date.distantFuture            let resetDescription = UsageFormatter.resetDescription(from: resetDate)            let creditWindow = RateWindow(                usedPercent: creditUsedPercent,                windowMinutes: nil,                resetsAt: resetDate,                resetDescription: resetDescription)             return UsageSnapshot(                primary: creditWindow,                secondary: nil,                tertiary: nil,                updatedAt: self.updatedAt,                identity: identity)        }         // Rate-window plans: five-hour window as primary, weekly as secondary.        // Five-hour window: primary        let fiveHourUsedPercent = max(0, min(100, (1.0 - self.fiveHourUsageLeftRate) * 100))        let fiveHourResetDescription = UsageFormatter.resetDescription(from: self.fiveHourUsageResetTime)        let fiveHourWindow = RateWindow(            usedPercent: fiveHourUsedPercent,            windowMinutes: 300,            resetsAt: self.fiveHourUsageResetTime,            resetDescription: fiveHourResetDescription)         // Weekly window: secondary        let weeklyUsedPercent = max(0, min(100, (1.0 - self.weeklyUsageLeftRate) * 100))        let weeklyResetDescription = UsageFormatter.resetDescription(from: self.weeklyUsageResetTime)        let weeklyWindow = RateWindow(            usedPercent: weeklyUsedPercent,            windowMinutes: 10080,            resetsAt: self.weeklyUsageResetTime,            resetDescription: weeklyResetDescription)         return UsageSnapshot(            primary: fiveHourWindow,            secondary: weeklyWindow,            tertiary: nil,            updatedAt: self.updatedAt,            identity: identity)    }} // MARK: - Errors public enum StepFunUsageError: LocalizedError, Sendable {    case missingCredentials    case missingToken    case networkError(String)    case apiError(String)    case parseFailed(String)    case loginFailed(String)    case tokenRefreshFailed(String)    case deviceRegistrationFailed(String)     public var errorDescription: String? {        switch self {        case .missingCredentials:            "Missing StepFun username or password. Set STEPFUN_USERNAME and STEPFUN_PASSWORD environment variables."        case .missingToken:            "Missing StepFun authentication token."        case let .networkError(message):            "StepFun network error: \(message)"        case let .apiError(message):            "StepFun API error: \(message)"        case let .parseFailed(message):            "Failed to parse StepFun response: \(message)"        case let .loginFailed(message):            "StepFun login failed: \(message)"        case let .tokenRefreshFailed(message):            "StepFun token refresh failed: \(message)"        case let .deviceRegistrationFailed(message):            "StepFun device registration failed: \(message)"        }    }} // MARK: - Fetcher public struct StepFunUsageFetcher: Sendable {    private static let log = CodexBarLog.logger(LogCategories.stepfunUsage)    private static let platformURL = URL(string: "https://platform.stepfun.com")!    private static let apiURL =        URL(string: "https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit")!    private static let planStatusURL =        URL(string: "https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus")!    private static let registerDeviceURL =        URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RegisterDevice")!    private static let loginURL =        URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/SignInByPassword")!    private static let refreshTokenURL =        URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RefreshToken")!    private static let timeoutSeconds: TimeInterval = 15     /// Fallback webid used only for the initial device-registration / login flow,    /// before we have a token to derive the real device_id from.    private static let defaultWebID = "c8a1002d2c457e758785a9979832217c7c0b884c"    private static let appID = "10300"     private static let baseHeaders: [String: String] = [        "content-type": "application/json",        "oasis-appid": appID,        "oasis-platform": "web",        "oasis-webid": defaultWebID,        "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",    ]     /// Extract the `device_id` from a token's JWT payload to use as the Oasis-Webid.    /// The refresh-token half of the "access...refresh" pair carries a `device_id`    /// claim that must match the Oasis-Webid header/cookie, otherwise the server    /// returns "auth failed: oasis-token is embezzled".    private static func webID(forToken token: String) -> String {        // The token is either a bare JWT or an "access...refresh" pair.        // The device_id lives in the refresh half; fall back to the access half.        let halves = token.components(separatedBy: "...")        for half in halves.reversed() {            if let webid = Self.extractDeviceID(from: half), !webid.isEmpty {                return webid            }        }        return Self.defaultWebID    }     /// Decode the JWT payload (without signature verification) and return `device_id`.    private static func extractDeviceID(from jwt: String) -> String? {        let parts = jwt.components(separatedBy: ".")        guard parts.count >= 2 else { return nil }        var payload = parts[1]        // base64url padding        while payload.count % 4 != 0 {            payload.append("=")        }        guard let data = Data(base64Encoded: payload.replacingOccurrences(of: "-", with: "+").replacingOccurrences(            of: "_",            with: "/")),            let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]        else { return nil }        return json["device_id"] as? String    }     // MARK: - Public API     /// Perform the full login flow (username + password → Oasis-Token) and return the token.    /// Does NOT fetch usage — the caller should cache the token and then call `fetchUsage(token:)`.    public static func login(username: String, password: String) async throws -> String {        try await self.fullLogin(username: username, password: password)    }     /// Refresh an existing Oasis-Token and return a fresh access + refresh token pair.    public static func refreshToken(token: String) async throws -> String {        try await self.refreshOasisToken(token: token)    }     /// Fetch usage data using an existing Oasis-Token (from env var or cached).    public static func fetchUsage(token: String) async throws -> StepFunUsageSnapshot {        guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {            throw StepFunUsageError.missingToken        }        return try await self.queryUsage(token: token)    }     /// Full login flow: username + password → token, then fetch usage.    public static func fetchUsage(username: String, password: String) async throws -> StepFunUsageSnapshot {        let token = try await self.fullLogin(username: username, password: password)        return try await self.queryUsage(token: token)    }     // MARK: - Login     private static func fullLogin(username: String, password: String) async throws -> String {        // Step 1: Get INGRESSCOOKIE by visiting the platform homepage        let (ingressCookie, _) = try await self.getIngressCookie()         // Step 2: RegisterDevice → get anonymous token        let anonToken = try await self.registerDevice(ingressCookie: ingressCookie)         // Step 3: SignInByPassword → get authenticated token        return try await self.signInByPassword(            username: username,            password: password,            ingressCookie: ingressCookie,            anonToken: anonToken)    }     private static func getIngressCookie() async throws -> (String, HTTPURLResponse) {        var request = URLRequest(url: self.platformURL)        request.httpMethod = "GET"        for (key, value) in self.baseHeaders {            request.setValue(value, forHTTPHeaderField: key)        }        request.timeoutInterval = self.timeoutSeconds         let response = try await ProviderHTTPClient.shared.response(for: request)        let httpResponse = response.response         // Extract INGRESSCOOKIE from Set-Cookie headers        let setCookieHeaders = httpResponse.allHeaderFields.filter { ($0.key as? String)?.lowercased() == "set-cookie" }        var ingressCookie = ""        for (_, value) in setCookieHeaders {            let cookieString = "\(value)"            if cookieString.contains("INGRESSCOOKIE=") {                let parts = cookieString.components(separatedBy: "INGRESSCOOKIE=")                if parts.count > 1 {                    let valuePart = parts[1].components(separatedBy: ";").first ?? ""                    ingressCookie = valuePart.trimmingCharacters(in: .whitespaces)                }            }        }         // Also check cookies from the URLSession cookie store        if ingressCookie.isEmpty {            let cookies = HTTPCookieStorage.shared.cookies(for: self.platformURL) ?? []            for cookie in cookies where cookie.name == "INGRESSCOOKIE" {                ingressCookie = cookie.value                break            }        }         guard !ingressCookie.isEmpty else {            throw StepFunUsageError.loginFailed("Could not obtain INGRESSCOOKIE")        }         return (ingressCookie, httpResponse)    }     private static func registerDevice(ingressCookie: String) async throws -> String {        var request = URLRequest(url: self.registerDeviceURL)        request.httpMethod = "POST"        request.httpBody = Data("{}".utf8)        for (key, value) in self.baseHeaders {            request.setValue(value, forHTTPHeaderField: key)        }        request.setValue("INGRESSCOOKIE=\(ingressCookie)", forHTTPHeaderField: "Cookie")        request.timeoutInterval = self.timeoutSeconds         let response = try await ProviderHTTPClient.shared.response(for: request)        let data = response.data        guard response.statusCode == 200 else {

Lines 1–500 of 740.