From 320e1367780d400201f0cc364b9baabd316bd4f0 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Tue, 26 May 2026 17:31:12 +0800 Subject: [PATCH 1/2] Refresh expired StepFun tokens --- Sources/CodexBarCLI/CLIUsageCommand.swift | 4 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 43 ++++ .../StepFun/StepFunProviderDescriptor.swift | 144 +++++++++-- .../StepFun/StepFunUsageFetcher.swift | 71 ++++- .../StepFunUsageFetcherTests.swift | 243 +++++++++++++++++- 5 files changed, 480 insertions(+), 25 deletions(-) diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index ddbfefaaf..b3618014c 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -288,7 +288,9 @@ extension CodexBarCLI { settings: settings, fetcher: tokenContext.fetcher(base: command.fetcher, provider: provider, env: env), claudeFetcher: command.claudeFetcher, - browserDetection: command.browserDetection) + browserDetection: command.browserDetection, + selectedTokenAccountID: account?.id, + tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account)) let outcome = await Self.fetchProviderUsage( provider: provider, context: fetchContext) diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index a4fbfc718..8fb392e99 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -346,6 +346,49 @@ struct TokenAccountCLIContext { return env } + func tokenUpdater(for account: ProviderTokenAccount?) -> ProviderFetchContext.TokenAccountTokenUpdater? { + guard let account else { return nil } + return { provider, accountID, token in + guard accountID == account.id else { return } + try? Self.updateStoredTokenAccount(provider: provider, accountID: accountID, token: token) + } + } + + private static func updateStoredTokenAccount( + provider: UsageProvider, + accountID: UUID, + token: String) throws + { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let store = CodexBarConfigStore() + guard var config = try store.load() else { return } + guard var providerConfig = config.providerConfig(for: provider), + let data = providerConfig.tokenAccounts, + let index = data.accounts.firstIndex(where: { $0.id == accountID }) + else { + return + } + + let existing = data.accounts[index] + var accounts = data.accounts + accounts[index] = ProviderTokenAccount( + id: existing.id, + label: existing.label, + token: trimmed, + addedAt: existing.addedAt, + lastUsed: existing.lastUsed, + externalIdentifier: existing.externalIdentifier, + organizationID: existing.organizationID) + providerConfig.tokenAccounts = ProviderTokenAccountData( + version: data.version, + accounts: accounts, + activeIndex: data.clampedActiveIndex()) + config.setProviderConfig(providerConfig) + try store.save(config) + } + func fetcher(base: UsageFetcher, provider: UsageProvider, env: [String: String]) -> UsageFetcher { guard provider == .codex else { return base } return UsageFetcher(environment: env) diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift index a8178d21e..e5e83cf23 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -51,22 +51,14 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { } 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) + let resolved = try await Self.resolveToken(context: context, allowCached: true) + let usage = try await StepFunUsageFetcher.fetchUsage(token: resolved.token) return self.makeResult( usage: usage.toUsageSnapshot(), sourceLabel: "web") + } catch let error where Self.isAuthenticationFailure(error) { + return try await self.recoverFromAuthenticationFailure(context: context, originalError: error) } } @@ -76,9 +68,22 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { // MARK: - Token Resolution + private struct ResolvedToken { + let token: String + let source: TokenSource + } + + private enum TokenSource { + case manual + case cached + case settingsLogin + case environmentToken + case environmentLogin + } + private static func resolveToken( context: ProviderFetchContext, - allowCached: Bool) async throws -> String + allowCached: Bool) async throws -> ResolvedToken { let settings = context.settings?.stepfun @@ -88,12 +93,16 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { guard !manualToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw StepFunUsageError.missingToken } - return StepFunTokenNormalizer.normalize(manualToken) + return ResolvedToken( + token: StepFunTokenNormalizer.normalize(manualToken), + source: .manual) } // 2. Cached token from previous login if allowCached, let cached = CookieHeaderCache.load(provider: .stepfun) { - return StepFunTokenNormalizer.normalize(cached.cookieHeader) + return ResolvedToken( + token: StepFunTokenNormalizer.normalize(cached.cookieHeader), + source: .cached) } // 3. Username + password from Settings UI → perform full login flow @@ -103,12 +112,12 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { username: settings.username, password: settings.password) CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") - return token + return ResolvedToken(token: token, source: .settingsLogin) } // 4. Direct token from env var if let token = StepFunSettingsReader.token(environment: context.env) { - return token + return ResolvedToken(token: token, source: .environmentToken) } // 5. Username + password from env vars → perform full login flow @@ -117,11 +126,110 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { { let token = try await StepFunUsageFetcher.login(username: username, password: password) CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") - return token + return ResolvedToken(token: token, source: .environmentLogin) } throw StepFunUsageError.missingCredentials } + + private func recoverFromAuthenticationFailure( + context: ProviderFetchContext, + originalError: Error) async throws -> ProviderFetchResult + { + let resolved = try await Self.resolveToken(context: context, allowCached: true) + do { + let refreshed = try await StepFunUsageFetcher.refreshToken(token: resolved.token) + await Self.persistRecoveredToken(refreshed, source: resolved.source, context: context) + let usage = try await StepFunUsageFetcher.fetchUsage(token: refreshed) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch { + if let loginToken = try await Self.loginTokenIfAvailable(context: context) { + let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } + throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError) + } + } + + private static func loginTokenIfAvailable(context: ProviderFetchContext) async throws -> String? { + let settings = context.settings?.stepfun + if settings?.cookieSource != .manual, + let settings, + !settings.username.isEmpty, + !settings.password.isEmpty + { + CookieHeaderCache.clear(provider: .stepfun) + let token = try await StepFunUsageFetcher.login( + username: settings.username, + password: settings.password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + if let username = StepFunSettingsReader.username(environment: context.env), + let password = StepFunSettingsReader.password(environment: context.env) + { + CookieHeaderCache.clear(provider: .stepfun) + let token = try await StepFunUsageFetcher.login(username: username, password: password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + return nil + } + + private static func persistRecoveredToken( + _ token: String, + source: TokenSource, + context: ProviderFetchContext) async + { + switch source { + case .cached, .settingsLogin, .environmentLogin: + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "refresh") + case .manual, .environmentToken: + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { return } + await updater(.stepfun, accountID, token) + } + } + + private static func isAuthenticationFailure(_ error: Error) -> Bool { + guard case let StepFunUsageError.apiError(message) = error else { + return false + } + let lower = message.lowercased() + return lower.contains("401") || + lower.contains("unauthorized") || + lower.contains("token") || + lower.contains("auth") + } + + private static func actionableAuthenticationError( + for source: TokenSource, + originalError: Error) -> StepFunUsageError + { + let suffix = switch source { + case .manual: + "Refresh the Oasis-Token, or switch StepFun to auto auth with username/password." + case .environmentToken: + "Refresh STEPFUN_TOKEN, or configure STEPFUN_USERNAME and STEPFUN_PASSWORD." + case .cached, .settingsLogin, .environmentLogin: + "Refresh the StepFun credentials and try again." + } + return .apiError("\(Self.authenticationFailureMessage(originalError)). \(suffix)") + } + + private static func authenticationFailureMessage(_ error: Error) -> String { + if case let StepFunUsageError.apiError(message) = error { + return message + } + return error.localizedDescription + } } // MARK: - Token Normalizer diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift index 8fdbdc8a0..9c8bca186 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -108,6 +108,11 @@ struct StepFunLoginResponse: Decodable { let refreshToken: StepFunTokenPair? } +struct StepFunRefreshTokenResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + struct StepFunTokenPair: Decodable { let raw: String } @@ -184,6 +189,7 @@ public enum StepFunUsageError: LocalizedError, Sendable { case apiError(String) case parseFailed(String) case loginFailed(String) + case tokenRefreshFailed(String) case deviceRegistrationFailed(String) public var errorDescription: String? { @@ -200,6 +206,8 @@ public enum StepFunUsageError: LocalizedError, Sendable { "Failed to parse StepFun response: \(message)" case let .loginFailed(message): "StepFun login failed: \(message)" + case let .tokenRefreshFailed(message): + "StepFun token refresh failed: \(message)" case let .deviceRegistrationFailed(message): "StepFun device registration failed: \(message)" } @@ -219,6 +227,8 @@ public struct StepFunUsageFetcher: Sendable { URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RegisterDevice")! private static let loginURL = URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/SignInByPassword")! + private static let refreshTokenURL = + URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RefreshToken")! private static let timeoutSeconds: TimeInterval = 15 private static let webID = "c8a1002d2c457e758785a9979832217c7c0b884c" @@ -241,6 +251,11 @@ public struct StepFunUsageFetcher: Sendable { try await self.fullLogin(username: username, password: password) } + /// Refresh an existing Oasis-Token and return a fresh access + refresh token pair. + public static func refreshToken(token: String) async throws -> String { + try await self.refreshOasisToken(token: token) + } + /// Fetch usage data using an existing Oasis-Token (from env var or cached). public static func fetchUsage(token: String) async throws -> StepFunUsageSnapshot { guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -342,9 +357,7 @@ public struct StepFunUsageFetcher: Sendable { 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)" + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) } private static func signInByPassword( @@ -384,7 +397,53 @@ public struct StepFunUsageFetcher: Sendable { throw StepFunUsageError.loginFailed("No access token in login response") } - let refreshToken = decoded.refreshToken?.raw ?? "" + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func refreshOasisToken(token: String) async throws -> String { + let normalized = StepFunTokenNormalizer.normalize(token) + guard !normalized.isEmpty else { + throw StepFunUsageError.missingToken + } + + var request = URLRequest(url: self.refreshTokenURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue(normalized, forHTTPHeaderField: "Oasis-Token") + request.setValue( + "Oasis-Token=\(normalized); Oasis-Webid=\(self.webID)", + forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun RefreshToken returned \(response.statusCode): \(body)") + throw StepFunUsageError.tokenRefreshFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunRefreshTokenResponse + do { + decoded = try JSONDecoder().decode(StepFunRefreshTokenResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("RefreshToken response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + throw StepFunUsageError.tokenRefreshFailed("No access token in refresh response") + } + + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func combinedToken(accessToken: String, refreshToken: String?) -> String { + guard let refreshToken, !refreshToken.isEmpty else { + return accessToken + } return "\(accessToken)...\(refreshToken)" } @@ -471,7 +530,9 @@ public struct StepFunUsageFetcher: Sendable { } guard decoded.isSuccess else { - let msg = decoded.message ?? decoded.code.map(String.init) ?? "unknown" + let msg = [decoded.message, decoded.desc] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? decoded.code.map(String.init) ?? "unknown" throw StepFunUsageError.apiError(msg) } diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift index ce06f27f9..19256467e 100644 --- a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore struct StepFunSettingsReaderTests { @Test @@ -231,3 +231,244 @@ struct StepFunTokenNormalizerTests { #expect(StepFunTokenNormalizer.normalize(" token123 ") == "token123") } } + +@Suite(.serialized) +struct StepFunTokenRefreshTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `refresh token returns combined token pair`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + #expect(request.url?.path.contains("RefreshToken") == true) + #expect(request.value(forHTTPHeaderField: "Oasis-Token") == "old-access...old-refresh") + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("old-access...old-refresh") == true) + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + let token = try await StepFunUsageFetcher.refreshToken(token: "old-access...old-refresh") + #expect(token == "new-access...new-refresh") + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `manual token auth failure refreshes token account and retries usage`() async throws { + let accountID = UUID() + let updateRecorder = StepFunTokenUpdateRecorder() + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("old-access...old-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("new-access...new-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + #expect(request.value(forHTTPHeaderField: "Oasis-Token") == "old-access...old-refresh") + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + selectedTokenAccountID: accountID, + tokenUpdater: { provider, updatedAccountID, token in + #expect(provider == .stepfun) + #expect(updatedAccountID == accountID) + await updateRecorder.record(token) + }) + + let result = try await StepFunWebFetchStrategy().fetch(context) + + #expect(result.usage.identity?.loginMethod == "Plus") + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + let updatedToken = await updateRecorder.recordedToken() + #expect(updatedToken == "new-access...new-refresh") + } + } + + private func makeContext( + settings: ProviderSettingsSnapshot?, + selectedTokenAccountID: UUID? = nil, + tokenUpdater: ProviderFetchContext.TokenAccountTokenUpdater? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + selectedTokenAccountID: selectedTokenAccountID, + tokenAccountTokenUpdater: tokenUpdater) + } + + private func withStubProtocol( + _ body: (StepFunRequestRecorder) async throws -> Void) async throws + { + let recorder = StepFunRequestRecorder() + let registered = URLProtocol.registerClass(StepFunStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(StepFunStubURLProtocol.self) + } + StepFunStubURLProtocol.handler = nil + } + try await body(recorder) + } + + private static func usageResponse(for request: URLRequest) -> (HTTPURLResponse, Data) { + self.jsonResponse( + for: request, + body: """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "weekly_usage_left_rate": 0.6, + "five_hour_usage_reset_time": "1777528800", + "weekly_usage_reset_time": "1777899600" + } + """) + } + + private static func jsonResponse( + for request: URLRequest, + statusCode: Int = 200, + body: String) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +private actor StepFunTokenUpdateRecorder { + private var token: String? + + func record(_ token: String) { + self.token = token + } + + func recordedToken() -> String? { + self.token + } +} + +private final class StepFunRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var usageCalls = 0 + private var refreshCalls = 0 + + var usageCallCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.usageCalls + } + + var refreshCallCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.refreshCalls + } + + func recordUsageCall() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + self.usageCalls += 1 + return self.usageCalls + } + + func recordRefreshCall() { + self.lock.lock() + defer { self.lock.unlock() } + self.refreshCalls += 1 + } +} + +private final class StepFunStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "platform.stepfun.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} From b726aa9cbc15fe5e5798cdd413307d40725774f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 27 May 2026 00:06:35 +0100 Subject: [PATCH 2/2] fix: refresh expired StepFun tokens Co-authored-by: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> --- CHANGELOG.md | 1 + Sources/CodexBar/ProviderRegistry.swift | 7 + .../CodexBar/UsageStore+TokenAccounts.swift | 7 + Sources/CodexBarCLI/CLIUsageCommand.swift | 3 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 33 +- .../Providers/ProviderFetchPlan.swift | 4 + .../StepFun/StepFunProviderDescriptor.swift | 78 +++- .../StepFunUsageFetcherTests.swift | 335 +++++++++++++++++- ...kenAccountEnvironmentPrecedenceTests.swift | 45 +++ 9 files changed, 497 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a51383d..a841d8cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.29.2 — Unreleased ### Fixed +- StepFun: refresh expired Oasis tokens and persist recovered manual sessions. Thanks @LeoLin990405! - Localization: improve Traditional Chinese wording and localize notification copy (#1158). Thanks @jack24254029! - Localization: improve Simplified Chinese visible menu, dashboard, and usage labels (#1145). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index b38f98753..eeeb541b1 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -74,6 +74,13 @@ struct ProviderRegistry { token: token) } }, + providerManualTokenUpdater: { provider, token in + await MainActor.run { + if provider == .stepfun { + settings.stepfunToken = token + } + } + }, costUsageHistoryDays: settings.costUsageHistoryDays) }) specs[provider] = spec diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index f9f40dbbf..4a5a96d4e 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -405,6 +405,13 @@ extension UsageStore { token: token) } }, + providerManualTokenUpdater: { [weak settings = self.settings] provider, token in + await MainActor.run { + if provider == .stepfun { + settings?.stepfunToken = token + } + } + }, costUsageHistoryDays: self.settings.costUsageHistoryDays) } diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index b3618014c..42506cef3 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -290,7 +290,8 @@ extension CodexBarCLI { claudeFetcher: command.claudeFetcher, browserDetection: command.browserDetection, selectedTokenAccountID: account?.id, - tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account)) + tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), + providerManualTokenUpdater: tokenContext.manualTokenUpdater()) let outcome = await Self.fetchProviderUsage( provider: provider, context: fetchContext) diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 8fb392e99..84dad7645 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -239,7 +239,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( cookieSource: cookieSource, - manualToken: cookieHeader ?? "", + manualToken: self.stepfunManualToken(account: account, config: config), username: config?.sanitizedAPIKey ?? "", password: "")) default: @@ -354,6 +354,25 @@ struct TokenAccountCLIContext { } } + func manualTokenUpdater() -> ProviderFetchContext.ProviderManualTokenUpdater { + { provider, token in + try? Self.updateStoredManualToken(provider: provider, token: token) + } + } + + private static func updateStoredManualToken(provider: UsageProvider, token: String) throws { + guard provider == .stepfun else { return } + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let store = CodexBarConfigStore() + var config = try store.load() ?? .makeDefault() + var providerConfig = config.providerConfig(for: provider) ?? ProviderConfig(id: provider) + providerConfig.region = trimmed + config.setProviderConfig(providerConfig) + try store.save(config) + } + private static func updateStoredTokenAccount( provider: UsageProvider, accountID: UUID, @@ -530,12 +549,24 @@ struct TokenAccountCLIContext { return .manual } if let override = config?.cookieSource { return override } + if provider == .stepfun, config?.sanitizedRegion != nil { + return .manual + } if config?.sanitizedCookieHeader != nil { return .manual } return .auto } + private func stepfunManualToken(account: ProviderTokenAccount?, config: ProviderConfig?) -> String { + if let account, + let support = TokenAccountSupportCatalog.support(for: .stepfun) + { + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + return config?.sanitizedRegion ?? config?.sanitizedCookieHeader ?? "" + } + private func resolveZaiRegion(_ config: ProviderConfig?) -> ZaiAPIRegion { guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index 005168275..e99a2c69f 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -19,6 +19,7 @@ public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable { public struct ProviderFetchContext: Sendable { public typealias TokenAccountTokenUpdater = @Sendable (UsageProvider, UUID, String) async -> Void + public typealias ProviderManualTokenUpdater = @Sendable (UsageProvider, String) async -> Void public let runtime: ProviderRuntime public let sourceMode: ProviderSourceMode @@ -34,6 +35,7 @@ public struct ProviderFetchContext: Sendable { public let browserDetection: BrowserDetection public let selectedTokenAccountID: UUID? public let tokenAccountTokenUpdater: TokenAccountTokenUpdater? + public let providerManualTokenUpdater: ProviderManualTokenUpdater? public let costUsageHistoryDays: Int public init( @@ -51,6 +53,7 @@ public struct ProviderFetchContext: Sendable { browserDetection: BrowserDetection, selectedTokenAccountID: UUID? = nil, tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil, + providerManualTokenUpdater: ProviderManualTokenUpdater? = nil, costUsageHistoryDays: Int = 30) { self.runtime = runtime @@ -67,6 +70,7 @@ public struct ProviderFetchContext: Sendable { self.browserDetection = browserDetection self.selectedTokenAccountID = selectedTokenAccountID self.tokenAccountTokenUpdater = tokenAccountTokenUpdater + self.providerManualTokenUpdater = providerManualTokenUpdater self.costUsageHistoryDays = max(1, min(365, costUsageHistoryDays)) } } diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift index e5e83cf23..5724db3b9 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -137,15 +137,41 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { originalError: Error) async throws -> ProviderFetchResult { let resolved = try await Self.resolveToken(context: context, allowCached: true) + let refreshed: String + do { + refreshed = try await StepFunUsageFetcher.refreshToken(token: resolved.token) + } catch { + if let fallback = try await Self.resolvedTokenWithoutStaleCache(context: context, source: resolved.source) { + do { + let usage = try await StepFunUsageFetcher.fetchUsage(token: fallback.token) + await Self.persistRecoveredToken(fallback.token, source: fallback.source, context: context) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch { + if !Self.isAuthenticationFailure(error) { + throw error + } + } + } + if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) { + let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } + throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError) + } + + await Self.persistRecoveredToken(refreshed, source: resolved.source, context: context) + do { - let refreshed = try await StepFunUsageFetcher.refreshToken(token: resolved.token) - await Self.persistRecoveredToken(refreshed, source: resolved.source, context: context) let usage = try await StepFunUsageFetcher.fetchUsage(token: refreshed) return self.makeResult( usage: usage.toUsageSnapshot(), sourceLabel: "web") - } catch { - if let loginToken = try await Self.loginTokenIfAvailable(context: context) { + } catch let retryError where Self.isAuthenticationFailure(retryError) { + if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) { let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken) return self.makeResult( usage: usage.toUsageSnapshot(), @@ -155,7 +181,29 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { } } - private static func loginTokenIfAvailable(context: ProviderFetchContext) async throws -> String? { + private static func resolvedTokenWithoutStaleCache( + context: ProviderFetchContext, + source: TokenSource) async throws -> ResolvedToken? + { + guard case .cached = source else { return nil } + CookieHeaderCache.clear(provider: .stepfun) + do { + return try await self.resolveToken(context: context, allowCached: false) + } catch StepFunUsageError.missingCredentials { + return nil + } catch StepFunUsageError.missingToken { + return nil + } + } + + private static func loginTokenIfAvailable( + context: ProviderFetchContext, + source: TokenSource) async throws -> String? + { + if case .manual = source { + return nil + } + let settings = context.settings?.stepfun if settings?.cookieSource != .manual, let settings, @@ -190,7 +238,15 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { switch source { case .cached, .settingsLogin, .environmentLogin: CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "refresh") - case .manual, .environmentToken: + case .manual: + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { + await context.providerManualTokenUpdater?(.stepfun, token) + return + } + await updater(.stepfun, accountID, token) + case .environmentToken: guard let accountID = context.selectedTokenAccountID, let updater = context.tokenAccountTokenUpdater else { return } @@ -202,11 +258,15 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { guard case let StepFunUsageError.apiError(message) = error else { return false } - let lower = message.lowercased() + let lower = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return lower.contains("401") || + lower.contains("403") || lower.contains("unauthorized") || - lower.contains("token") || - lower.contains("auth") + lower.contains("unauthenticated") || + lower.contains("invalid credentials") || + lower.contains("invalid token") || + lower.contains("token expired") || + lower.contains("expired token") } private static func actionableAuthenticationError( diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift index 19256467e..2c1ee8503 100644 --- a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -337,10 +337,333 @@ struct StepFunTokenRefreshTests { } } + @Test + func `manual token auth failure refreshes settings token and retries usage`() async throws { + let updateRecorder = StepFunTokenUpdateRecorder() + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("new-access...new-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + manualTokenUpdater: { provider, token in + #expect(provider == .stepfun) + await updateRecorder.record(token) + }) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + let updatedToken = await updateRecorder.recordedToken() + #expect(updatedToken == "new-access...new-refresh") + } + } + + @Test + func `stale cached token falls back to configured env token`() async throws { + CookieHeaderCache.store(provider: .stepfun, cookieHeader: "stale-access...stale-refresh", sourceLabel: "test") + defer { CookieHeaderCache.clear(provider: .stepfun) } + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("stale-access...stale-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("env-access...env-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(cookieSource: .auto)) + let context = self.makeContext( + settings: settings, + env: ["STEPFUN_TOKEN": "env-access...env-refresh"]) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + #expect(CookieHeaderCache.load(provider: .stepfun) == nil) + } + } + + @Test + func `stale cached and env tokens fall back to env login credentials`() async throws { + CookieHeaderCache.store(provider: .stepfun, cookieHeader: "stale-access...stale-refresh", sourceLabel: "test") + defer { CookieHeaderCache.clear(provider: .stepfun) } + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.isEmpty || path == "/" { + return Self.jsonResponse( + for: request, + body: "{}", + headers: ["Set-Cookie": "INGRESSCOOKIE=ingress-cookie; Path=/"]) + } + + if path.contains("RegisterDevice") { + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "anon-access"}, + "refreshToken": {"raw": "anon-refresh"} + } + """) + } + + if path.contains("SignInByPassword") { + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "login-access"}, + "refreshToken": {"raw": "login-refresh"} + } + """) + } + + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("stale-access...stale-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + if call == 2 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("env-access...env-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("login-access...login-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(cookieSource: .auto)) + let context = self.makeContext( + settings: settings, + env: [ + "STEPFUN_TOKEN": "env-access...env-refresh", + "STEPFUN_USERNAME": "user@example.com", + "STEPFUN_PASSWORD": "password", + ]) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 3) + #expect(recorder.refreshCallCount == 1) + #expect(CookieHeaderCache.load(provider: .stepfun)?.cookieHeader == "login-access...login-refresh") + } + } + + @Test + func `post refresh non auth usage failure is not rewritten as auth guidance`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + return Self.jsonResponse(for: request, statusCode: 500, body: #"{"error":"temporary"}"#) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext(settings: settings) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected post-refresh usage failure") + } catch let StepFunUsageError.apiError(message) { + #expect(message == "HTTP 500") + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `manual token refresh failure does not fall back to ambient env credentials`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + _ = recorder.recordUsageCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + Issue.record("Manual token recovery should not call login endpoint: \(path)") + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + env: [ + "STEPFUN_USERNAME": "someone@example.com", + "STEPFUN_PASSWORD": "secret", + ]) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected manual token auth failure") + } catch let StepFunUsageError.apiError(message) { + #expect(message.contains("Refresh the Oasis-Token")) + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 1) + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `non auth token wording does not trigger refresh recovery`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + _ = recorder.recordUsageCall() + return Self.jsonResponse( + for: request, + body: #"{"status":0,"message":"token plan status temporarily unavailable"}"#) + } + + Issue.record("Non-auth usage error should not call recovery endpoint: \(path)") + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext(settings: settings) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected provider API error") + } catch let StepFunUsageError.apiError(message) { + #expect(message == "token plan status temporarily unavailable") + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 1) + #expect(recorder.refreshCallCount == 0) + } + } + private func makeContext( settings: ProviderSettingsSnapshot?, + env: [String: String] = [:], selectedTokenAccountID: UUID? = nil, - tokenUpdater: ProviderFetchContext.TokenAccountTokenUpdater? = nil) -> ProviderFetchContext + tokenUpdater: ProviderFetchContext.TokenAccountTokenUpdater? = nil, + manualTokenUpdater: ProviderFetchContext.ProviderManualTokenUpdater? = nil) -> ProviderFetchContext { ProviderFetchContext( runtime: .app, @@ -349,13 +672,14 @@ struct StepFunTokenRefreshTests { webTimeout: 1, webDebugDumpHTML: false, verbose: false, - env: [:], + env: env, settings: settings, fetcher: UsageFetcher(environment: [:]), claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0), selectedTokenAccountID: selectedTokenAccountID, - tokenAccountTokenUpdater: tokenUpdater) + tokenAccountTokenUpdater: tokenUpdater, + providerManualTokenUpdater: manualTokenUpdater) } private func withStubProtocol( @@ -389,13 +713,14 @@ struct StepFunTokenRefreshTests { private static func jsonResponse( for request: URLRequest, statusCode: Int = 200, - body: String) -> (HTTPURLResponse, Data) + body: String, + headers: [String: String] = ["Content-Type": "application/json"]) -> (HTTPURLResponse, Data) { let response = HTTPURLResponse( url: request.url!, statusCode: statusCode, httpVersion: nil, - headerFields: ["Content-Type": "application/json"])! + headerFields: headers)! return (response, Data(body.utf8)) } } diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index a7cb75e1a..14dfe6392 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -107,6 +107,51 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(ollamaSettings.manualCookieHeader == "session=account-token") } + @Test + func `stepfun CLI snapshot reads manual token from region field`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .stepfun, + region: "Oasis-Token=manual-token; Oasis-Webid=web"), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .stepfun, account: nil)) + let stepfunSettings = try #require(snapshot.stepfun) + + #expect(stepfunSettings.cookieSource == .manual) + #expect(stepfunSettings.manualToken == "Oasis-Token=manual-token; Oasis-Webid=web") + } + + @Test + func `stepfun CLI token account overrides region manual token`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "StepFun", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .stepfun, + region: "manual-token", + tokenAccounts: ProviderTokenAccountData( + version: 1, + accounts: [account], + activeIndex: 0)), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let resolvedAccount = try #require(tokenContext.resolvedAccounts(for: .stepfun).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .stepfun, account: resolvedAccount)) + let stepfunSettings = try #require(snapshot.stepfun) + + #expect(stepfunSettings.cookieSource == .manual) + #expect(stepfunSettings.manualToken == "account-token") + } + @Test func `claude OAuth token account overrides environment in app environment builder`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-app")