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. Load more
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 {