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 { 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? 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" } public var isSuccess: Bool { self.status == 1 } } // 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 init( fiveHourUsageLeftRate: Double, weeklyUsageLeftRate: Double, fiveHourUsageResetTime: Date, weeklyUsageResetTime: Date, planName: String? = nil, updatedAt: Date) { self.fiveHourUsageLeftRate = fiveHourUsageLeftRate self.weeklyUsageLeftRate = weeklyUsageLeftRate self.fiveHourUsageResetTime = fiveHourUsageResetTime self.weeklyUsageResetTime = weeklyUsageResetTime self.planName = planName self.updatedAt = updatedAt } public func toUsageSnapshot() -> UsageSnapshot { // 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) 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) 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 private static let webID = "c8a1002d2c457e758785a9979832217c7c0b884c" private static let appID = "10300" private static let baseHeaders: [String: String] = [ "content-type": "application/json", "oasis-appid": appID, "oasis-platform": "web", "oasis-webid": webID, "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", ] // 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 { let body = String(data: data, encoding: .utf8) ?? "" Self.log.error("StepFun RegisterDevice returned \(response.statusCode): \(body)") throw StepFunUsageError.deviceRegistrationFailed("HTTP \(response.statusCode)") } let decoded: StepFunRegisterDeviceResponse do { decoded = try JSONDecoder().decode(StepFunRegisterDeviceResponse.self, from: data) } catch { throw StepFunUsageError.parseFailed("RegisterDevice response: \(error.localizedDescription)") } guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { throw StepFunUsageError.deviceRegistrationFailed("No access token in RegisterDevice response") } return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) } private static func signInByPassword( username: String, password: String, ingressCookie: String, anonToken: String) async throws -> String { var request = URLRequest(url: self.loginURL) request.httpMethod = "POST" let body: [String: String] = ["username": username, "password": password] request.httpBody = try? JSONSerialization.data(withJSONObject: body) for (key, value) in self.baseHeaders { request.setValue(value, forHTTPHeaderField: key) } request.setValue( "Oasis-Token=\(anonToken); Oasis-Webid=\(self.webID); 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 { let body = String(data: data, encoding: .utf8) ?? "" Self.log.error("StepFun SignInByPassword returned \(response.statusCode): \(body)") throw StepFunUsageError.loginFailed("HTTP \(response.statusCode)") } let decoded: StepFunLoginResponse do { decoded = try JSONDecoder().decode(StepFunLoginResponse.self, from: data) } catch { throw StepFunUsageError.parseFailed("SignInByPassword response: \(error.localizedDescription)") } guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { throw StepFunUsageError.loginFailed("No access token in login response") } return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) } private static func refreshOasisToken(token: String) async throws -> String { let normalized = StepFunTokenNormalizer.normalize(token) guard !normalized.isEmpty else { throw StepFunUsageError.missingToken } var request = URLRequest(url: self.refreshTokenURL) request.httpMethod = "POST" request.httpBody = Data("{}".utf8) for (key, value) in self.baseHeaders { request.setValue(value, forHTTPHeaderField: key) } request.setValue(normalized, forHTTPHeaderField: "Oasis-Token") request.setValue( "Oasis-Token=\(normalized); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") request.timeoutInterval = self.timeoutSeconds 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) ?? "" Self.log.error("StepFun RefreshToken returned \(response.statusCode): \(body)") throw StepFunUsageError.tokenRefreshFailed("HTTP \(response.statusCode)") } let decoded: StepFunRefreshTokenResponse do { decoded = try JSONDecoder().decode(StepFunRefreshTokenResponse.self, from: data) } catch { throw StepFunUsageError.parseFailed("RefreshToken response: \(error.localizedDescription)") } guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { throw StepFunUsageError.tokenRefreshFailed("No access token in refresh response") } return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) } private static func combinedToken(accessToken: String, refreshToken: String?) -> String { guard let refreshToken, !refreshToken.isEmpty else { return accessToken } return "\(accessToken)...\(refreshToken)" } // MARK: - Query usage private static func queryUsage(token: String) async throws -> StepFunUsageSnapshot { var request = URLRequest(url: self.apiURL) request.httpMethod = "POST" request.httpBody = Data("{}".utf8) for (key, value) in self.baseHeaders { request.setValue(value, forHTTPHeaderField: key) } request.setValue("Oasis-Token=\(token); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") request.timeoutInterval = self.timeoutSeconds 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) ?? "" Self.log.error("StepFun API returned \(response.statusCode): \(body)") throw StepFunUsageError.apiError("HTTP \(response.statusCode)") } if let jsonString = String(data: data, encoding: .utf8) { Self.log.debug("StepFun API response: \(jsonString)") } var snapshot = try self.parseSnapshot(data: data) // Fetch plan name in parallel is not needed — just do it sequentially. // If plan status fails, we still return usage data without plan name. if let planName = try? await self.queryPlanStatus(token: token) { snapshot = StepFunUsageSnapshot( fiveHourUsageLeftRate: snapshot.fiveHourUsageLeftRate, weeklyUsageLeftRate: snapshot.weeklyUsageLeftRate, fiveHourUsageResetTime: snapshot.fiveHourUsageResetTime, weeklyUsageResetTime: snapshot.weeklyUsageResetTime, planName: planName, updatedAt: snapshot.updatedAt) } return snapshot } // MARK: - Plan Status private static func queryPlanStatus(token: String) async throws -> String? { var request = URLRequest(url: self.planStatusURL) request.httpMethod = "POST" request.httpBody = Data("{}".utf8) for (key, value) in self.baseHeaders { request.setValue(value, forHTTPHeaderField: key) } request.setValue("Oasis-Token=\(token); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") request.timeoutInterval = self.timeoutSeconds let response = try await ProviderHTTPClient.shared.response(for: request) guard response.statusCode == 200 else { Self.log.debug("StepFun plan status request failed, skipping plan name") return nil } let decoded: StepFunPlanStatusResponse do { decoded = try JSONDecoder().decode(StepFunPlanStatusResponse.self, from: response.data) } catch { Self.log.debug("StepFun plan status parse failed: \(error.localizedDescription)") return nil } return decoded.planName } public static func _parseSnapshotForTesting(_ data: Data) throws -> StepFunUsageSnapshot { try self.parseSnapshot(data: data) } private static func parseSnapshot(data: Data) throws -> StepFunUsageSnapshot { let decoded: StepFunRateLimitResponse do { decoded = try JSONDecoder().decode(StepFunRateLimitResponse.self, from: data) } catch { throw StepFunUsageError.parseFailed(error.localizedDescription) } guard decoded.isSuccess else { let msg = [decoded.message, decoded.desc] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .first { !$0.isEmpty } ?? decoded.code.map(String.init) ?? "unknown" throw StepFunUsageError.apiError(msg) } guard let fiveHourRate = decoded.fiveHourUsageLeftRate, let weeklyRate = decoded.weeklyUsageLeftRate, let fiveHourReset = decoded.fiveHourUsageResetTime, let weeklyReset = decoded.weeklyUsageResetTime else { throw StepFunUsageError.parseFailed("Missing usage rate or reset time fields") } return StepFunUsageSnapshot( fiveHourUsageLeftRate: fiveHourRate.value, weeklyUsageLeftRate: weeklyRate.value, fiveHourUsageResetTime: Date(timeIntervalSince1970: TimeInterval(fiveHourReset.value)), weeklyUsageResetTime: Date(timeIntervalSince1970: TimeInterval(weeklyReset.value)), updatedAt: Date()) } }