chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:33 +08:00
commit fc4fcbab58
1848 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,315 @@
import Foundation
public enum StepFunProviderDescriptor {
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
static func makeDescriptor() -> ProviderDescriptor {
ProviderDescriptor(
id: .stepfun,
metadata: ProviderMetadata(
id: .stepfun,
displayName: "StepFun",
sessionLabel: "5h Window",
weeklyLabel: "Weekly Window",
opusLabel: nil,
supportsOpus: false,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show StepFun usage",
cliName: "stepfun",
defaultEnabled: false,
isPrimaryProvider: false,
usesAccountFallback: false,
browserCookieOrder: nil,
dashboardURL: "https://platform.stepfun.com/plan-usage",
statusPageURL: nil,
statusLinkURL: nil),
branding: ProviderBranding(
iconStyle: .stepfun,
iconResourceName: "ProviderIcon-stepfun",
color: ProviderColor(red: 0.13, green: 0.59, blue: 0.95)),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "StepFun per-day cost history is not available via API." }),
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .web],
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [StepFunWebFetchStrategy()] })),
cli: ProviderCLIConfig(
name: "stepfun",
aliases: ["step-fun", "sf"],
versionDetector: nil))
}
}
struct StepFunWebFetchStrategy: ProviderFetchStrategy {
let id: String = "stepfun.web"
let kind: ProviderFetchKind = .web
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
context.settings?.stepfun?.cookieSource != .off
}
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
do {
let resolved = try await Self.resolveToken(context: context, allowCached: true)
let usage = try await StepFunUsageFetcher.fetchUsage(token: resolved.token)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "web")
} catch let error where Self.isAuthenticationFailure(error) {
return try await self.recoverFromAuthenticationFailure(context: context, originalError: error)
}
}
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
// MARK: - Token Resolution
private struct ResolvedToken {
let token: String
let source: TokenSource
}
private enum TokenSource {
case manual
case cached
case settingsLogin
case environmentToken
case environmentLogin
}
private static func resolveToken(
context: ProviderFetchContext,
allowCached: Bool) async throws -> ResolvedToken
{
let settings = context.settings?.stepfun
// 1. Manual mode: use the token directly from settings
if settings?.cookieSource == .manual {
let manualToken = settings?.manualToken ?? ""
guard !manualToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw StepFunUsageError.missingToken
}
return ResolvedToken(
token: StepFunTokenNormalizer.normalize(manualToken),
source: .manual)
}
// 2. Cached token from previous login
if allowCached, let cached = CookieHeaderCache.load(provider: .stepfun) {
return ResolvedToken(
token: StepFunTokenNormalizer.normalize(cached.cookieHeader),
source: .cached)
}
// 3. Username + password from Settings UI perform full login flow
// (register device sign in by password get Oasis-Token)
if let settings, !settings.username.isEmpty, !settings.password.isEmpty {
let token = try await StepFunUsageFetcher.login(
username: settings.username,
password: settings.password)
CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login")
return ResolvedToken(token: token, source: .settingsLogin)
}
// 4. Direct token from env var
if let token = StepFunSettingsReader.token(environment: context.env) {
return ResolvedToken(token: token, source: .environmentToken)
}
// 5. Username + password from env vars perform full login flow
if let username = StepFunSettingsReader.username(environment: context.env),
let password = StepFunSettingsReader.password(environment: context.env)
{
let token = try await StepFunUsageFetcher.login(username: username, password: password)
CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login")
return ResolvedToken(token: token, source: .environmentLogin)
}
throw StepFunUsageError.missingCredentials
}
private func recoverFromAuthenticationFailure(
context: ProviderFetchContext,
originalError: Error) async throws -> ProviderFetchResult
{
let resolved = try await Self.resolveToken(context: context, allowCached: true)
let refreshed: String
do {
refreshed = try await StepFunUsageFetcher.refreshToken(token: resolved.token)
} catch {
if let fallback = try await Self.resolvedTokenWithoutStaleCache(context: context, source: resolved.source) {
do {
let usage = try await StepFunUsageFetcher.fetchUsage(token: fallback.token)
await Self.persistRecoveredToken(fallback.token, source: fallback.source, context: context)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "web")
} catch {
if !Self.isAuthenticationFailure(error) {
throw error
}
}
}
if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) {
let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "web")
}
throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError)
}
await Self.persistRecoveredToken(refreshed, source: resolved.source, context: context)
do {
let usage = try await StepFunUsageFetcher.fetchUsage(token: refreshed)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "web")
} catch let retryError where Self.isAuthenticationFailure(retryError) {
if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) {
let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "web")
}
throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError)
}
}
private static func resolvedTokenWithoutStaleCache(
context: ProviderFetchContext,
source: TokenSource) async throws -> ResolvedToken?
{
guard case .cached = source else { return nil }
CookieHeaderCache.clear(provider: .stepfun)
do {
return try await self.resolveToken(context: context, allowCached: false)
} catch StepFunUsageError.missingCredentials {
return nil
} catch StepFunUsageError.missingToken {
return nil
}
}
private static func loginTokenIfAvailable(
context: ProviderFetchContext,
source: TokenSource) async throws -> String?
{
if case .manual = source {
return nil
}
let settings = context.settings?.stepfun
if settings?.cookieSource != .manual,
let settings,
!settings.username.isEmpty,
!settings.password.isEmpty
{
CookieHeaderCache.clear(provider: .stepfun)
let token = try await StepFunUsageFetcher.login(
username: settings.username,
password: settings.password)
CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login")
return token
}
if let username = StepFunSettingsReader.username(environment: context.env),
let password = StepFunSettingsReader.password(environment: context.env)
{
CookieHeaderCache.clear(provider: .stepfun)
let token = try await StepFunUsageFetcher.login(username: username, password: password)
CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login")
return token
}
return nil
}
private static func persistRecoveredToken(
_ token: String,
source: TokenSource,
context: ProviderFetchContext) async
{
switch source {
case .cached, .settingsLogin, .environmentLogin:
CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "refresh")
case .manual:
guard let accountID = context.selectedTokenAccountID,
let updater = context.tokenAccountTokenUpdater
else {
await context.providerManualTokenUpdater?(.stepfun, token)
return
}
await updater(.stepfun, accountID, token)
case .environmentToken:
guard let accountID = context.selectedTokenAccountID,
let updater = context.tokenAccountTokenUpdater
else { return }
await updater(.stepfun, accountID, token)
}
}
private static func isAuthenticationFailure(_ error: Error) -> Bool {
guard case let StepFunUsageError.apiError(message) = error else {
return false
}
let lower = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return lower.contains("401") ||
lower.contains("403") ||
lower.contains("unauthorized") ||
lower.contains("unauthenticated") ||
lower.contains("invalid credentials") ||
lower.contains("invalid token") ||
lower.contains("token expired") ||
lower.contains("expired token")
}
private static func actionableAuthenticationError(
for source: TokenSource,
originalError: Error) -> StepFunUsageError
{
let suffix = switch source {
case .manual:
"Refresh the Oasis-Token, or switch StepFun to auto auth with username/password."
case .environmentToken:
"Refresh STEPFUN_TOKEN, or configure STEPFUN_USERNAME and STEPFUN_PASSWORD."
case .cached, .settingsLogin, .environmentLogin:
"Refresh the StepFun credentials and try again."
}
return .apiError("\(Self.authenticationFailureMessage(originalError)). \(suffix)")
}
private static func authenticationFailureMessage(_ error: Error) -> String {
if case let StepFunUsageError.apiError(message) = error {
return message
}
return error.localizedDescription
}
}
// MARK: - Token Normalizer
public enum StepFunTokenNormalizer {
/// Normalize a StepFun token value extracts the Oasis-Token from a cookie header
/// or returns the raw token value if it's not a cookie header.
public static func normalize(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
// If it looks like a cookie header, extract Oasis-Token
if trimmed.contains("Oasis-Token=") {
let parts = trimmed.components(separatedBy: "Oasis-Token=")
if parts.count > 1 {
let afterToken = parts[1]
return afterToken.components(separatedBy: ";").first?
.trimmingCharacters(in: .whitespaces) ?? afterToken
}
}
return trimmed
}
}
@@ -0,0 +1,38 @@
import Foundation
public struct StepFunSettingsReader: Sendable {
public static let usernameEnvironmentKey = "STEPFUN_USERNAME"
public static let passwordEnvironmentKey = "STEPFUN_PASSWORD"
public static let tokenEnvironmentKey = "STEPFUN_TOKEN"
public static func username(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
self.cleaned(environment[self.usernameEnvironmentKey])
}
public static func password(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
self.cleaned(environment[self.passwordEnvironmentKey])
}
public static func token(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
self.cleaned(environment[self.tokenEnvironmentKey])
}
private static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
(value.hasPrefix("'") && value.hasSuffix("'"))
{
value = String(value.dropFirst().dropLast())
}
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
@@ -0,0 +1,554 @@
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())
}
}