From b7ac17f99e281c4d31f607ac15ab46a2fd02f3bf Mon Sep 17 00:00:00 2001 From: Teven Feng Date: Thu, 30 Apr 2026 14:02:43 +0800 Subject: [PATCH 1/5] Add StepFun provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add StepFun (阶跃星辰) as a new web-based provider that monitors Step Plan rate limits via the platform.stepfun.com API. Authentication flow: - Auto mode: username + password login via 3-step flow (INGRESSCOOKIE → RegisterDevice → SignInByPassword → Oasis-Token) - Manual mode: direct Oasis-Token paste - Token cached in CookieHeaderCache (Keychain-backed) Usage data: - Primary: 5-hour rate limit window (five_hour_usage_left_rate) - Secondary: weekly rate limit window (weekly_usage_left_rate) - Reset times from API (supports both string and int timestamp formats) Files added: - CodexBarCore/Providers/StepFun/: descriptor, fetcher, settings reader - CodexBar/Providers/StepFun/: implementation, settings store - ProviderIcon-stepfun.svg, StepFunUsageFetcherTests.swift --- .../ProviderImplementationRegistry.swift | 1 + .../StepFunProviderImplementation.swift | 161 +++++++ .../StepFun/StepFunSettingsStore.swift | 65 +++ .../Resources/ProviderIcon-stepfun.svg | 33 ++ Sources/CodexBar/UsageStore.swift | 3 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 8 +- .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 36 +- .../Providers/ProviderTokenResolver.swift | 12 + .../CodexBarCore/Providers/Providers.swift | 3 + .../StepFun/StepFunProviderDescriptor.swift | 164 +++++++ .../StepFun/StepFunSettingsReader.swift | 39 ++ .../StepFun/StepFunUsageFetcher.swift | 430 ++++++++++++++++++ .../TokenAccountSupportCatalog+Data.swift | 7 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + Tests/CodexBarTests/SettingsStoreTests.swift | 1 + .../StepFunUsageFetcherTests.swift | 233 ++++++++++ 20 files changed, 1195 insertions(+), 9 deletions(-) create mode 100644 Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-stepfun.svg create mode 100644 Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift create mode 100644 Tests/CodexBarTests/StepFunUsageFetcherTests.swift 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..f5b8ff769 --- /dev/null +++ b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift @@ -0,0 +1,65 @@ +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 { + self.stepfunToken + } + + private func stepfunSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + self.stepfunCookieSource + } +} 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..ad287c7c4 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -201,7 +201,7 @@ struct TokenAccountCLIContext { cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp, .deepseek, - .codebuff: + .codebuff, .stepfun: return nil case .windsurf: return nil @@ -226,7 +226,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 +247,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..1d343f1d2 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -33,6 +33,8 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case mistral case deepseek case codebuff + case stepfun +} } // swiftformat:enable sortDeclarations @@ -68,6 +70,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..62b6a7c5e --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -0,0 +1,164 @@ +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 { + // 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..cdeade2fb --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -0,0 +1,430 @@ +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: - 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 updatedAt: Date + + public init( + fiveHourUsageLeftRate: Double, + weeklyUsageLeftRate: Double, + fiveHourUsageResetTime: Date, + weeklyUsageResetTime: Date, + updatedAt: Date) + { + self.fiveHourUsageLeftRate = fiveHourUsageLeftRate + self.weeklyUsageLeftRate = weeklyUsageLeftRate + self.fiveHourUsageResetTime = fiveHourUsageResetTime + self.weeklyUsageResetTime = weeklyUsageResetTime + 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 identity = ProviderIdentitySnapshot( + providerID: .stepfun, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "password") + + 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 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)") + } + + return try self.parseSnapshot(data: data) + } + + 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") + } +} From 49a5c27dd5eb75cd4394b7ebac997f5755c10bd5 Mon Sep 17 00:00:00 2001 From: Teven Feng Date: Thu, 30 Apr 2026 14:16:30 +0800 Subject: [PATCH 2/5] Fix PR review feedback: token account overrides, Off auth mode, CLI snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply token-account overrides in StepFun settings snapshot so per-account usage fetching works correctly (P1) - Respect Off auth source in isAvailable — don't fetch when cookieSource is .off (P2) - Add StepFun case to CLI snapshot builder so --account/--all-accounts can pass tokens through settings (P2) --- .../StepFun/StepFunSettingsStore.swift | 24 +++++++++++++++++-- Sources/CodexBarCLI/TokenAccountCLI.swift | 11 ++++++++- .../StepFun/StepFunProviderDescriptor.swift | 4 ++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift index f5b8ff769..cf3d6e854 100644 --- a/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift +++ b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift @@ -56,10 +56,30 @@ extension SettingsStore { } private func stepfunSnapshotToken(tokenOverride: TokenAccountOverride?) -> String { - self.stepfunToken + 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 { - self.stepfunCookieSource + 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/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index ad287c7c4..c768b7444 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -200,8 +200,17 @@ 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, .stepfun: + .codebuff: return nil case .windsurf: return nil diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift index 62b6a7c5e..b467f7607 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -47,6 +47,10 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { 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 From 662018316328807a3c65fe9097e7443270617d79 Mon Sep 17 00:00:00 2001 From: Teven Feng Date: Thu, 30 Apr 2026 14:36:46 +0800 Subject: [PATCH 3/5] Add StepFun plan name support via GetStepPlanStatus API - Fetch subscription plan name from GetStepPlanStatus endpoint - Display plan name (e.g. Plus, Mini) as loginMethod in UI - Gracefully degrade: if plan status fails, usage still shows --- .../StepFun/StepFunUsageFetcher.swift | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift index cdeade2fb..f99071df9 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -73,6 +73,29 @@ public struct StepFunRateLimitResponse: Decodable, Sendable { } } +// 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 { @@ -96,6 +119,7 @@ public struct StepFunUsageSnapshot: Sendable { public let weeklyUsageLeftRate: Double public let fiveHourUsageResetTime: Date public let weeklyUsageResetTime: Date + public let planName: String? public let updatedAt: Date public init( @@ -103,12 +127,14 @@ public struct StepFunUsageSnapshot: Sendable { 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 } @@ -131,11 +157,14 @@ public struct StepFunUsageSnapshot: Sendable { 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: "password") + loginMethod: loginMethod) return UsageSnapshot( primary: fiveHourWindow, @@ -183,6 +212,7 @@ 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 @@ -392,7 +422,51 @@ public struct StepFunUsageFetcher: Sendable { Self.log.debug("StepFun API response: \(jsonString)") } - return try self.parseSnapshot(data: data) + 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 { From 670e1837a6a0a73c8cd4188a8db8b359caa418f6 Mon Sep 17 00:00:00 2001 From: Teven Feng Date: Thu, 30 Apr 2026 15:12:04 +0800 Subject: [PATCH 4/5] Add StepFun provider documentation - Add StepFun entry to Providers section in README.md - Add docs/stepfun.md with auth flow, data sources, and key files --- README.md | 1 + docs/stepfun.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 docs/stepfun.md 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/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) From 19981badfe9d2dd4763b56f8f56836c92498fb17 Mon Sep 17 00:00:00 2001 From: Teven Feng Date: Wed, 6 May 2026 09:53:27 +0800 Subject: [PATCH 5/5] Fix merge conflict: remove duplicate closing brace in Providers.swift --- Sources/CodexBarCore/Providers/Providers.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 1d343f1d2..f3be14f25 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -35,7 +35,6 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case codebuff case stepfun } -} // swiftformat:enable sortDeclarations