chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user