diff --git a/README.md b/README.md
index ce90b8566..9f7ae4bdd 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,7 @@ Or download release tarballs from GitHub Releases:
- Mistral — Browser cookies for monthly spend tracking.
- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown).
- [Codebuff](docs/codebuff.md) — API token (or `~/.config/manicode/credentials.json`) for credit balance + weekly rate limit.
+- [StepFun](docs/stepfun.md) — Username + password login for Step Plan rate limits (5‑hour + weekly windows) and subscription plan name.
- Open to new providers: [provider authoring guide](docs/provider.md).
## Icon & Screenshot
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index ebf7ffcd2..e0b648c37 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -43,6 +43,7 @@ enum ProviderImplementationRegistry {
case .mistral: MistralProviderImplementation()
case .deepseek: DeepSeekProviderImplementation()
case .codebuff: CodebuffProviderImplementation()
+ case .stepfun: StepFunProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift b/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift
new file mode 100644
index 000000000..dec72a8c8
--- /dev/null
+++ b/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift
@@ -0,0 +1,161 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct StepFunProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .stepfun
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { _ in "web" }
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.stepfunCookieSource
+ _ = settings.stepfunUsername
+ _ = settings.stepfunPassword
+ _ = settings.stepfunToken
+ }
+
+ @MainActor
+ func isAvailable(context: ProviderAvailabilityContext) -> Bool {
+ // Available if any auth method is configured
+ if !context.settings.stepfunUsername.isEmpty, !context.settings.stepfunPassword.isEmpty {
+ return true
+ }
+ if context.settings.stepfunCookieSource == .manual, !context.settings.stepfunToken.isEmpty {
+ return true
+ }
+ if CookieHeaderCache.load(provider: .stepfun) != nil {
+ return true
+ }
+ if StepFunSettingsReader.username(environment: context.environment) != nil,
+ StepFunSettingsReader.password(environment: context.environment) != nil
+ {
+ return true
+ }
+ if StepFunSettingsReader.token(environment: context.environment) != nil {
+ return true
+ }
+ return false
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .stepfun(context.settings.stepfunSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool {
+ guard support.requiresManualCookieSource else { return true }
+ if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true }
+ return context.settings.stepfunCookieSource == .manual
+ }
+
+ @MainActor
+ func applyTokenAccountCookieSource(settings: SettingsStore) {
+ if settings.stepfunCookieSource != .manual {
+ settings.stepfunCookieSource = .manual
+ }
+ }
+
+ // MARK: - Settings Pickers
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.stepfunCookieSource.rawValue },
+ set: { raw in
+ context.settings.stepfunCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let cookieOptions = ProviderCookieSourceUI.options(
+ allowsOff: true,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+
+ let cookieSubtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.stepfunCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Uses username + password to login and obtain an Oasis-Token automatically.",
+ manual: "Manually paste an Oasis-Token from a browser session.",
+ off: "StepFun authentication is disabled.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "stepfun-cookie-source",
+ title: "Auth source",
+ subtitle: "Uses username + password to login and obtain an Oasis-Token automatically.",
+ dynamicSubtitle: cookieSubtitle,
+ binding: cookieBinding,
+ options: cookieOptions,
+ isVisible: nil,
+ onChange: nil,
+ trailingText: {
+ guard let entry = CookieHeaderCache.load(provider: .stepfun) else { return nil }
+ let when = entry.storedAt.relativeDescription()
+ return "Cached: \(entry.sourceLabel) • \(when)"
+ }),
+ ]
+ }
+
+ // MARK: - Settings Fields
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ // Auto mode: show username + password fields
+ let autoFields: [ProviderSettingsFieldDescriptor] = [
+ ProviderSettingsFieldDescriptor(
+ id: "stepfun-username",
+ title: "Username",
+ subtitle: "StepFun platform account (phone number or email).",
+ kind: .plain,
+ placeholder: "user@example.com",
+ binding: context.stringBinding(\.stepfunUsername),
+ actions: [],
+ isVisible: { context.settings.stepfunCookieSource != .manual },
+ onActivate: nil),
+ ProviderSettingsFieldDescriptor(
+ id: "stepfun-password",
+ title: "Password",
+ subtitle: "Your StepFun platform password. Used to login and obtain a session token.",
+ kind: .secure,
+ placeholder: "Password",
+ binding: context.stringBinding(\.stepfunPassword),
+ actions: [],
+ isVisible: { context.settings.stepfunCookieSource != .manual },
+ onActivate: nil),
+ ]
+
+ // Manual mode: show token field
+ let manualFields: [ProviderSettingsFieldDescriptor] = [
+ ProviderSettingsFieldDescriptor(
+ id: "stepfun-token",
+ title: "Oasis-Token",
+ subtitle: "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com.",
+ kind: .secure,
+ placeholder: "Oasis-Token=…",
+ binding: context.stringBinding(\.stepfunToken),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "stepfun-open-platform",
+ title: "Open StepFun Platform",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://platform.stepfun.com/plan-usage") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.stepfunCookieSource == .manual },
+ onActivate: nil),
+ ]
+
+ return autoFields + manualFields
+ }
+}
diff --git a/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift
new file mode 100644
index 000000000..cf3d6e854
--- /dev/null
+++ b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift
@@ -0,0 +1,85 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ /// Username for StepFun login — stored in the apiKey config field.
+ var stepfunUsername: String {
+ get { self.configSnapshot.providerConfig(for: .stepfun)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .stepfun) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logProviderModeChange(provider: .stepfun, field: "username", value: newValue.isEmpty ? "(cleared)" : "(updated)")
+ }
+ }
+
+ /// Password for StepFun login — stored in the cookieHeader config field (secure storage).
+ var stepfunPassword: String {
+ get { self.configSnapshot.providerConfig(for: .stepfun)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .stepfun) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .stepfun, field: "password", value: newValue)
+ }
+ }
+
+ /// Manual Oasis-Token — stored in the region config field (repurposed for token).
+ var stepfunToken: String {
+ get { self.configSnapshot.providerConfig(for: .stepfun)?.region ?? "" }
+ set {
+ self.updateProviderConfig(provider: .stepfun) { entry in
+ entry.region = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .stepfun, field: "token", value: newValue)
+ }
+ }
+
+ var stepfunCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .stepfun, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .stepfun) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .stepfun, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+
+ func stepfunSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot
+ .StepFunProviderSettings
+ {
+ ProviderSettingsSnapshot.StepFunProviderSettings(
+ cookieSource: self.stepfunSnapshotCookieSource(tokenOverride: tokenOverride),
+ manualToken: self.stepfunSnapshotToken(tokenOverride: tokenOverride),
+ username: self.stepfunUsername,
+ password: self.stepfunPassword)
+ }
+
+ private func stepfunSnapshotToken(tokenOverride: TokenAccountOverride?) -> String {
+ let fallback = self.stepfunToken
+ guard let support = TokenAccountSupportCatalog.support(for: .stepfun),
+ case .cookieHeader = support.injection
+ else {
+ return fallback
+ }
+ guard let account = ProviderTokenAccountSelection.selectedAccount(
+ provider: .stepfun,
+ settings: self,
+ override: tokenOverride)
+ else {
+ return fallback
+ }
+ return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support)
+ }
+
+ private func stepfunSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource {
+ let fallback = self.stepfunCookieSource
+ guard let support = TokenAccountSupportCatalog.support(for: .stepfun),
+ support.requiresManualCookieSource
+ else {
+ return fallback
+ }
+ if self.tokenAccounts(for: .stepfun).isEmpty { return fallback }
+ return .manual
+ }
+}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-stepfun.svg b/Sources/CodexBar/Resources/ProviderIcon-stepfun.svg
new file mode 100644
index 000000000..915c71d2c
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-stepfun.svg
@@ -0,0 +1,33 @@
+
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 00ac59400..c563d8cbe 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -831,6 +831,7 @@ extension UsageStore {
.kimi: "Kimi debug log not yet implemented",
.kimik2: "Kimi K2 debug log not yet implemented",
.jetbrains: "JetBrains AI debug log not yet implemented",
+ .stepfun: "StepFun debug log not yet implemented",
]
let buildText = {
switch provider {
@@ -904,7 +905,7 @@ extension UsageStore {
hasEnvToken: deepSeekHasEnvToken,
hasTokenAccount: deepSeekHasTokenAccount)
case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
- .kimik2, .jetbrains, .perplexity, .abacus, .mistral, .codebuff, .windsurf:
+ .kimik2, .jetbrains, .perplexity, .abacus, .mistral, .codebuff, .stepfun, .windsurf:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index b27b05a90..c768b7444 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -200,6 +200,15 @@ struct TokenAccountCLIContext {
mistral: ProviderSettingsSnapshot.MistralProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
+ case .stepfun:
+ let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
+ let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
+ return self.makeSnapshot(
+ stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(
+ cookieSource: cookieSource,
+ manualToken: cookieHeader ?? "",
+ username: config?.sanitizedAPIKey ?? "",
+ password: ""))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp, .deepseek,
.codebuff:
return nil
@@ -226,7 +235,8 @@ struct TokenAccountCLIContext {
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil,
abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil,
- mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil,
+ stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
@@ -246,7 +256,8 @@ struct TokenAccountCLIContext {
jetbrains: jetbrains,
perplexity: perplexity,
abacus: abacus,
- mistral: mistral)
+ mistral: mistral,
+ stepfun: stepfun)
}
private func makeCodexSettingsSnapshot(account: ProviderTokenAccount?) ->
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index ae1d10a34..74ad9464a 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -70,4 +70,5 @@ public enum LogCategories {
public static let zaiSettings = "zai-settings"
public static let zaiTokenStore = "zai-token-store"
public static let zaiUsage = "zai-usage"
+ public static let stepfunUsage = "stepfun-usage"
}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 07c85c33a..fe6e0fb22 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -83,6 +83,7 @@ public enum ProviderDescriptorRegistry {
.mistral: MistralProviderDescriptor.descriptor,
.deepseek: DeepSeekProviderDescriptor.descriptor,
.codebuff: CodebuffProviderDescriptor.descriptor,
+ .stepfun: StepFunProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index 88efa8283..725b05f3f 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -23,7 +23,8 @@ public struct ProviderSettingsSnapshot: Sendable {
windsurf: WindsurfProviderSettings? = nil,
perplexity: PerplexityProviderSettings? = nil,
abacus: AbacusProviderSettings? = nil,
- mistral: MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
+ mistral: MistralProviderSettings? = nil,
+ stepfun: StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
debugMenuEnabled: debugMenuEnabled,
@@ -47,7 +48,8 @@ public struct ProviderSettingsSnapshot: Sendable {
windsurf: windsurf,
perplexity: perplexity,
abacus: abacus,
- mistral: mistral)
+ mistral: mistral,
+ stepfun: stepfun)
}
public struct CodexProviderSettings: Sendable {
@@ -280,6 +282,25 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct StepFunProviderSettings: Sendable {
+ public let cookieSource: ProviderCookieSource
+ public let manualToken: String
+ public let username: String
+ public let password: String
+
+ public init(
+ cookieSource: ProviderCookieSource = .auto,
+ manualToken: String = "",
+ username: String = "",
+ password: String = "")
+ {
+ self.cookieSource = cookieSource
+ self.manualToken = manualToken
+ self.username = username
+ self.password = password
+ }
+ }
+
public let debugMenuEnabled: Bool
public let debugKeepCLISessionsAlive: Bool
public let codex: CodexProviderSettings?
@@ -302,6 +323,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let perplexity: PerplexityProviderSettings?
public let abacus: AbacusProviderSettings?
public let mistral: MistralProviderSettings?
+ public let stepfun: StepFunProviderSettings?
public var jetbrainsIDEBasePath: String? {
self.jetbrains?.ideBasePath
@@ -329,7 +351,8 @@ public struct ProviderSettingsSnapshot: Sendable {
windsurf: WindsurfProviderSettings? = nil,
perplexity: PerplexityProviderSettings? = nil,
abacus: AbacusProviderSettings? = nil,
- mistral: MistralProviderSettings? = nil)
+ mistral: MistralProviderSettings? = nil,
+ stepfun: StepFunProviderSettings? = nil)
{
self.debugMenuEnabled = debugMenuEnabled
self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive
@@ -353,6 +376,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.perplexity = perplexity
self.abacus = abacus
self.mistral = mistral
+ self.stepfun = stepfun
}
}
@@ -377,6 +401,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings)
case abacus(ProviderSettingsSnapshot.AbacusProviderSettings)
case mistral(ProviderSettingsSnapshot.MistralProviderSettings)
+ case stepfun(ProviderSettingsSnapshot.StepFunProviderSettings)
}
public struct ProviderSettingsSnapshotBuilder: Sendable {
@@ -402,6 +427,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings?
public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings?
public var mistral: ProviderSettingsSnapshot.MistralProviderSettings?
+ public var stepfun: ProviderSettingsSnapshot.StepFunProviderSettings?
public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) {
self.debugMenuEnabled = debugMenuEnabled
@@ -430,6 +456,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .perplexity(value): self.perplexity = value
case let .abacus(value): self.abacus = value
case let .mistral(value): self.mistral = value
+ case let .stepfun(value): self.stepfun = value
}
}
@@ -456,6 +483,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
windsurf: self.windsurf,
perplexity: self.perplexity,
abacus: self.abacus,
- mistral: self.mistral)
+ mistral: self.mistral,
+ stepfun: self.stepfun)
}
}
diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
index db70aa069..fc4aa9d07 100644
--- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
+++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
@@ -77,6 +77,12 @@ public enum ProviderTokenResolver {
self.deepseekResolution(environment: environment)?.token
}
+ public static func stepfunToken(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ self.stepfunResolution(environment: environment)?.token
+ }
+
public static func deepseekResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
@@ -90,6 +96,12 @@ public enum ProviderTokenResolver {
self.codebuffResolution(environment: environment, authFileURL: authFileURL)?.token
}
+ public static func stepfunResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(StepFunSettingsReader.token(environment: environment))
+ }
+
public static func zaiResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 0418061cb..f3be14f25 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -33,6 +33,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case mistral
case deepseek
case codebuff
+ case stepfun
}
// swiftformat:enable sortDeclarations
@@ -68,6 +69,7 @@ public enum IconStyle: Sendable, CaseIterable {
case mistral
case deepseek
case codebuff
+ case stepfun
case combined
}
diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift
new file mode 100644
index 000000000..b467f7607
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift
@@ -0,0 +1,168 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum StepFunProviderDescriptor {
+ 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 {
+ // Respect the "Off" auth source — if explicitly disabled, don't fetch.
+ if let settings = context.settings?.stepfun, settings.cookieSource == .off {
+ return false
+ }
+ // Available if any auth method is configured:
+ // 1. Username + password from Settings UI
+ // 2. Manual token from Settings UI
+ // 3. Cached token from previous login
+ // 4. Username + password from env vars
+ // 5. Direct token from env var
+ if let settings = context.settings?.stepfun {
+ if !settings.username.isEmpty, !settings.password.isEmpty { return true }
+ if settings.cookieSource == .manual, !settings.manualToken.isEmpty { return true }
+ }
+ if CookieHeaderCache.load(provider: .stepfun) != nil { return true }
+ if StepFunSettingsReader.username(environment: context.env) != nil,
+ StepFunSettingsReader.password(environment: context.env) != nil
+ { return true }
+ if StepFunSettingsReader.token(environment: context.env) != nil { return true }
+ return false
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let cookieSource = context.settings?.stepfun?.cookieSource ?? .auto
+
+ do {
+ let token = try await Self.resolveToken(context: context, allowCached: true)
+ let usage = try await StepFunUsageFetcher.fetchUsage(token: token)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "web")
+ } catch StepFunUsageError.apiError where cookieSource != .manual {
+ // Token may be stale — clear cache and retry with fresh login
+ CookieHeaderCache.clear(provider: .stepfun)
+ let token = try await Self.resolveToken(context: context, allowCached: false)
+ let usage = try await StepFunUsageFetcher.fetchUsage(token: token)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "web")
+ }
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ // MARK: - Token Resolution
+
+ private static func resolveToken(
+ context: ProviderFetchContext,
+ allowCached: Bool) async throws -> String
+ {
+ 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 StepFunTokenNormalizer.normalize(manualToken)
+ }
+
+ // 2. Cached token from previous login
+ if allowCached, let cached = CookieHeaderCache.load(provider: .stepfun) {
+ return StepFunTokenNormalizer.normalize(cached.cookieHeader)
+ }
+
+ // 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 token
+ }
+
+ // 4. Direct token from env var
+ if let token = StepFunSettingsReader.token(environment: context.env) {
+ return token
+ }
+
+ // 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 token
+ }
+
+ throw StepFunUsageError.missingCredentials
+ }
+}
+
+// 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]
+ let tokenValue = afterToken.components(separatedBy: ";").first?
+ .trimmingCharacters(in: .whitespaces) ?? afterToken
+ return tokenValue
+ }
+ }
+
+ return trimmed
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift
new file mode 100644
index 000000000..1d19a9ba9
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift
@@ -0,0 +1,39 @@
+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.removeFirst()
+ value.removeLast()
+ }
+ value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift
new file mode 100644
index 000000000..f99071df9
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift
@@ -0,0 +1,504 @@
+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, Sendable {
+ let status: Int?
+ let subscription: StepFunSubscription?
+
+ var planName: String? {
+ subscription?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
+
+struct StepFunSubscription: Decodable, Sendable {
+ 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, Sendable {
+ let accessToken: StepFunTokenPair?
+ let refreshToken: StepFunTokenPair?
+}
+
+struct StepFunLoginResponse: Decodable, Sendable {
+ let accessToken: StepFunTokenPair?
+ let refreshToken: StepFunTokenPair?
+}
+
+struct StepFunTokenPair: Decodable, Sendable {
+ 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 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 .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 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)
+ }
+
+ /// 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
+ let authToken = try await self.signInByPassword(
+ username: username,
+ password: password,
+ ingressCookie: ingressCookie,
+ anonToken: anonToken)
+
+ return authToken
+ }
+
+ 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 URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw StepFunUsageError.networkError("Invalid response fetching platform page")
+ }
+
+ // 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 (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw StepFunUsageError.networkError("Invalid response from RegisterDevice")
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let body = String(data: data, encoding: .utf8) ?? ""
+ Self.log.error("StepFun RegisterDevice returned \(httpResponse.statusCode): \(body)")
+ throw StepFunUsageError.deviceRegistrationFailed("HTTP \(httpResponse.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")
+ }
+
+ let refreshToken = decoded.refreshToken?.raw ?? ""
+ // Combine access + refresh tokens like the Python tool does
+ return "\(accessToken)...\(refreshToken)"
+ }
+
+ 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=\(webID); INGRESSCOOKIE=\(ingressCookie)", forHTTPHeaderField: "Cookie")
+ request.timeoutInterval = self.timeoutSeconds
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw StepFunUsageError.networkError("Invalid response from SignInByPassword")
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let body = String(data: data, encoding: .utf8) ?? ""
+ Self.log.error("StepFun SignInByPassword returned \(httpResponse.statusCode): \(body)")
+ throw StepFunUsageError.loginFailed("HTTP \(httpResponse.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")
+ }
+
+ let refreshToken = decoded.refreshToken?.raw ?? ""
+ 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=\(webID)", forHTTPHeaderField: "Cookie")
+ request.timeoutInterval = self.timeoutSeconds
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw StepFunUsageError.networkError("Invalid response")
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let body = String(data: data, encoding: .utf8) ?? ""
+ Self.log.error("StepFun API returned \(httpResponse.statusCode): \(body)")
+ throw StepFunUsageError.apiError("HTTP \(httpResponse.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=\(webID)", forHTTPHeaderField: "Cookie")
+ request.timeoutInterval = self.timeoutSeconds
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.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: 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.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())
+ }
+}
diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
index 94e1d3f7e..5c4f0be15 100644
--- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
+++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
@@ -93,5 +93,12 @@ extension TokenAccountSupportCatalog {
injection: .environment(key: "COPILOT_API_TOKEN"),
requiresManualCookieSource: false,
cookieName: nil),
+ .stepfun: TokenAccountSupport(
+ title: "Session tokens",
+ subtitle: "Store multiple StepFun Oasis-Token values.",
+ placeholder: "Oasis-Token=…",
+ injection: .cookieHeader,
+ requiresManualCookieSource: true,
+ cookieName: nil),
]
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 7c4bfb10f..a8c894b5e 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -236,7 +236,7 @@ enum CostUsageScanner {
case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot,
.minimax, .kilo, .kiro, .kimi,
.kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus,
- .mistral, .deepseek, .codebuff, .windsurf:
+ .mistral, .deepseek, .codebuff, .stepfun, .windsurf:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 3b5053fcc..22741a05f 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -81,6 +81,7 @@ enum ProviderChoice: String, AppEnum {
case .mistral: return nil // Mistral not yet supported in widgets
case .deepseek: return nil // DeepSeek not yet supported in widgets
case .codebuff: return nil // Codebuff not yet supported in widgets
+ case .stepfun: return nil // StepFun not yet supported in widgets
}
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 9944d39d8..747a8793c 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -287,6 +287,7 @@ private struct ProviderSwitchChip: View {
case .mistral: "Mistral"
case .deepseek: "DeepSeek"
case .codebuff: "Codebuff"
+ case .stepfun: "StepFun"
}
}
}
@@ -656,6 +657,8 @@ enum WidgetColors {
Color(red: 82 / 255, green: 125 / 255, blue: 240 / 255)
case .codebuff:
Color(red: 68 / 255, green: 255 / 255, blue: 0 / 255) // Codebuff lime
+ case .stepfun:
+ Color(red: 255 / 255, green: 140 / 255, blue: 0 / 255) // StepFun orange
}
}
}
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index e5cbc09d4..554225872 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -968,6 +968,7 @@ struct SettingsStoreTests {
.mistral,
.deepseek,
.codebuff,
+ .stepfun,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift
new file mode 100644
index 000000000..44c5aef82
--- /dev/null
+++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift
@@ -0,0 +1,233 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+struct StepFunSettingsReaderTests {
+ @Test
+ func `reads STEPFUN_TOKEN`() {
+ let env = ["STEPFUN_TOKEN": "some-oasis-token-value"]
+ #expect(StepFunSettingsReader.token(environment: env) == "some-oasis-token-value")
+ }
+
+ @Test
+ func `reads STEPFUN_USERNAME`() {
+ let env = ["STEPFUN_USERNAME": "user@example.com"]
+ #expect(StepFunSettingsReader.username(environment: env) == "user@example.com")
+ }
+
+ @Test
+ func `reads STEPFUN_PASSWORD`() {
+ let env = ["STEPFUN_PASSWORD": "secret123"]
+ #expect(StepFunSettingsReader.password(environment: env) == "secret123")
+ }
+
+ @Test
+ func `trims whitespace from token`() {
+ let env = ["STEPFUN_TOKEN": " some-token "]
+ #expect(StepFunSettingsReader.token(environment: env) == "some-token")
+ }
+
+ @Test
+ func `strips double quotes from token`() {
+ let env = ["STEPFUN_TOKEN": "\"some-token\""]
+ #expect(StepFunSettingsReader.token(environment: env) == "some-token")
+ }
+
+ @Test
+ func `strips single quotes from token`() {
+ let env = ["STEPFUN_TOKEN": "'some-token'"]
+ #expect(StepFunSettingsReader.token(environment: env) == "some-token")
+ }
+
+ @Test
+ func `returns nil when no env vars present`() {
+ #expect(StepFunSettingsReader.token(environment: [:]) == nil)
+ #expect(StepFunSettingsReader.username(environment: [:]) == nil)
+ #expect(StepFunSettingsReader.password(environment: [:]) == nil)
+ }
+
+ @Test
+ func `returns nil for empty values`() {
+ let env = ["STEPFUN_TOKEN": "", "STEPFUN_USERNAME": "", "STEPFUN_PASSWORD": ""]
+ #expect(StepFunSettingsReader.token(environment: env) == nil)
+ #expect(StepFunSettingsReader.username(environment: env) == nil)
+ #expect(StepFunSettingsReader.password(environment: env) == nil)
+ }
+
+ @Test
+ func `returns nil for whitespace-only values`() {
+ let env = ["STEPFUN_TOKEN": " "]
+ #expect(StepFunSettingsReader.token(environment: env) == nil)
+ }
+}
+
+struct StepFunProviderTokenResolverTests {
+ @Test
+ func `resolves token from environment`() {
+ let env = ["STEPFUN_TOKEN": "my-test-token"]
+ let resolution = ProviderTokenResolver.stepfunResolution(environment: env)
+ #expect(resolution?.token == "my-test-token")
+ #expect(resolution?.source == .environment)
+ }
+
+ @Test
+ func `returns nil when token absent`() {
+ let resolution = ProviderTokenResolver.stepfunResolution(environment: [:])
+ #expect(resolution == nil)
+ }
+}
+
+struct StepFunUsageFetcherParsingTests {
+ @Test
+ func `parses real API response format with string timestamps and integer rates`() throws {
+ // This matches the actual StepFun API response format:
+ // - timestamps as strings (e.g. "1777528800")
+ // - rates can be integers (e.g. 1) or floats (e.g. 0.99781543)
+ let json = """
+ {
+ "status": 1,
+ "desc": "",
+ "five_hour_usage_left_rate": 1,
+ "five_hour_usage_reset_time": "1777528800",
+ "weekly_usage_left_rate": 0.99781543,
+ "weekly_usage_reset_time": "1777899600"
+ }
+ """
+ let data = Data(json.utf8)
+ let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data)
+
+ #expect(snapshot.fiveHourUsageLeftRate == 1.0)
+ #expect(snapshot.weeklyUsageLeftRate > 0.997 && snapshot.weeklyUsageLeftRate < 0.998)
+ }
+
+ @Test
+ func `parses response with float rates and integer timestamps`() throws {
+ let json = """
+ {
+ "status": 1,
+ "five_hour_usage_left_rate": 0.75,
+ "weekly_usage_left_rate": 0.5,
+ "five_hour_usage_reset_time": 1746000000,
+ "weekly_usage_reset_time": 1746500000
+ }
+ """
+ let data = Data(json.utf8)
+ let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data)
+
+ #expect(snapshot.fiveHourUsageLeftRate == 0.75)
+ #expect(snapshot.weeklyUsageLeftRate == 0.5)
+ }
+
+ @Test
+ func `throws on failed API status`() {
+ let json = """
+ {
+ "status": 0,
+ "message": "Unauthorized",
+ "five_hour_usage_left_rate": 0.75,
+ "weekly_usage_left_rate": 0.5,
+ "five_hour_usage_reset_time": "1746000000",
+ "weekly_usage_reset_time": "1746500000"
+ }
+ """
+ let data = Data(json.utf8)
+ #expect(throws: StepFunUsageError.self) {
+ try StepFunUsageFetcher._parseSnapshotForTesting(data)
+ }
+ }
+
+ @Test
+ func `throws on missing fields`() {
+ let json = """
+ {
+ "status": 1
+ }
+ """
+ let data = Data(json.utf8)
+ #expect(throws: StepFunUsageError.self) {
+ try StepFunUsageFetcher._parseSnapshotForTesting(data)
+ }
+ }
+
+ @Test
+ func `throws on invalid JSON`() {
+ let data = Data("not json".utf8)
+ #expect(throws: StepFunUsageError.self) {
+ try StepFunUsageFetcher._parseSnapshotForTesting(data)
+ }
+ }
+
+ @Test
+ func `snapshot maps to UsageSnapshot correctly`() throws {
+ let json = """
+ {
+ "status": 1,
+ "five_hour_usage_left_rate": 0.8,
+ "weekly_usage_left_rate": 0.6,
+ "five_hour_usage_reset_time": "1746000000",
+ "weekly_usage_reset_time": "1746500000"
+ }
+ """
+ let data = Data(json.utf8)
+ let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data)
+ let usage = snapshot.toUsageSnapshot()
+
+ // Five-hour window: 20% used (1.0 - 0.8)
+ let primaryUsed = usage.primary?.usedPercent ?? 0
+ #expect(primaryUsed > 19.9 && primaryUsed < 20.1)
+
+ // Weekly window: 40% used (1.0 - 0.6)
+ let secondaryUsed = usage.secondary?.usedPercent ?? 0
+ #expect(secondaryUsed > 39.9 && secondaryUsed < 40.1)
+ #expect(usage.secondary?.windowMinutes == 10080)
+
+ // Identity
+ #expect(usage.identity?.providerID == .stepfun)
+ #expect(usage.identity?.loginMethod == "password")
+ }
+
+ @Test
+ func `clamps used percent to 0-100 range`() throws {
+ let json = """
+ {
+ "status": 1,
+ "five_hour_usage_left_rate": 0.0,
+ "weekly_usage_left_rate": 1,
+ "five_hour_usage_reset_time": "1746000000",
+ "weekly_usage_reset_time": "1746500000"
+ }
+ """
+ let data = Data(json.utf8)
+ let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data)
+ let usage = snapshot.toUsageSnapshot()
+
+ // 0% remaining → 100% used
+ #expect(usage.primary?.usedPercent == 100.0)
+ // 100% remaining → 0% used (integer 1 parsed as 1.0)
+ #expect(usage.secondary?.usedPercent == 0.0)
+ }
+}
+
+struct StepFunTokenNormalizerTests {
+ @Test
+ func `extracts Oasis-Token from cookie header`() {
+ let input = "Oasis-Token=abc123...def456; Oasis-Webid=someid"
+ #expect(StepFunTokenNormalizer.normalize(input) == "abc123...def456")
+ }
+
+ @Test
+ func `returns raw value when not a cookie header`() {
+ let input = "abc123...def456"
+ #expect(StepFunTokenNormalizer.normalize(input) == "abc123...def456")
+ }
+
+ @Test
+ func `returns empty for empty string`() {
+ #expect(StepFunTokenNormalizer.normalize("") == "")
+ }
+
+ @Test
+ func `trims whitespace`() {
+ #expect(StepFunTokenNormalizer.normalize(" token123 ") == "token123")
+ }
+}
diff --git a/docs/stepfun.md b/docs/stepfun.md
new file mode 100644
index 000000000..cba78d33d
--- /dev/null
+++ b/docs/stepfun.md
@@ -0,0 +1,57 @@
+---
+summary: "StepFun provider data sources: username + password login for Step Plan rate limits and subscription plan name."
+read_when:
+ - Adding or tweaking StepFun rate limit parsing
+ - Updating StepFun login flow
+ - Documenting new provider behavior
+---
+
+# StepFun provider
+
+StepFun (阶跃星辰) is a web-based provider. Usage data comes from the Step Plan rate limit API,
+authenticated via an Oasis-Token obtained through a username + password login flow.
+
+## Data sources
+
+1. **Authentication** — Three methods (in priority order):
+ - **Auto mode**: Username + password entered in Settings → Providers → StepFun.
+ CodexBar performs a 3-step login flow to obtain an Oasis-Token:
+ 1. `GET https://platform.stepfun.com` → `INGRESSCOOKIE`
+ 2. `POST …/RegisterDevice` → anonymous token
+ 3. `POST …/SignInByPassword` → authenticated Oasis-Token
+ The token is cached in Keychain-backed `CookieHeaderCache` and reused until it expires.
+ - **Manual mode**: Paste an Oasis-Token directly in Settings → Providers → StepFun.
+ - **Environment variables**: `STEPFUN_USERNAME` + `STEPFUN_PASSWORD`, or `STEPFUN_TOKEN`.
+
+2. **Rate limit endpoint**
+ - `POST https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit`
+ - Request headers: `Cookie: Oasis-Token=`, `Content-Type: application/json`
+ - Response fields:
+ - `five_hour_usage_left_rate` — remaining fraction for the 5-hour window (e.g. `0.99781543`)
+ - `weekly_usage_left_rate` — remaining fraction for the weekly window
+ - `five_hour_usage_reset_time` — reset timestamp (string or integer)
+ - `weekly_usage_reset_time` — reset timestamp (string or integer)
+
+3. **Plan status endpoint**
+ - `POST https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus`
+ - Same auth headers as above
+ - Response → `subscription.name` → plan name (e.g. "Plus", "Mini")
+ - If this request fails, usage data is still displayed without a plan name.
+
+## Usage details
+
+- **Primary window** (top bar): 5-hour rate limit (300 minutes).
+- **Secondary window** (bottom bar): weekly rate limit (10 080 minutes).
+- `usedPercent` is computed as `(1.0 - left_rate) × 100`.
+- Plan name is shown as the `loginMethod` label in the menu card (e.g. "Plus").
+- When auth source is set to **Off**, no background refreshes occur.
+- Token expiry triggers automatic re-login (cache is cleared and the 3-step flow runs again).
+
+## Key files
+
+- `Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift` (descriptor + web fetch strategy)
+- `Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift` (login flow + HTTP client + JSON parser)
+- `Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift` (env var resolution)
+- `Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift` (settings fields + activation logic)
+- `Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift` (SettingsStore extension)
+- `Tests/CodexBarTests/StepFunUsageFetcherTests.swift` (22 test cases)