Files
steipete--codexbar/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift
T
2026-07-13 12:22:33 +08:00

555 lines
22 KiB
Swift

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