From dbc28ab99554ed1ae581e90441460a99e25c4ce3 Mon Sep 17 00:00:00 2001 From: YanxinXue Date: Fri, 22 May 2026 09:55:34 +0800 Subject: [PATCH 1/9] Add Alibaba Token Plan provider --- Sources/CodexBar/MenuCardView.swift | 11 +- ...ibabaTokenPlanProviderImplementation.swift | 93 ++ .../AlibabaTokenPlanSettingsStore.swift | 30 + .../ProviderImplementationRegistry.swift | 1 + Sources/CodexBar/UsageStore.swift | 25 + Sources/CodexBarCLI/TokenAccountCLI.swift | 7 + .../Generated/CodexParserHash.generated.swift | 2 +- .../AlibabaTokenPlanCookieHeader.swift | 152 +++ .../Alibaba/AlibabaTokenPlanDebugProbe.swift | 133 +++ .../AlibabaTokenPlanProviderDescriptor.swift | 254 +++++ .../AlibabaTokenPlanSettingsReader.swift | 61 ++ .../AlibabaTokenPlanUsageFetcher.swift | 885 ++++++++++++++++++ .../AlibabaTokenPlanUsageSnapshot.swift | 96 ++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 19 + .../CodexBarCore/Providers/Providers.swift | 1 + .../Vendored/CostUsage/CostUsageScanner.swift | 3 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 +- .../AlibabaTokenPlanProviderTests.swift | 515 ++++++++++ .../ProviderSettingsDescriptorTests.swift | 45 + 21 files changed, 2330 insertions(+), 8 deletions(-) create mode 100644 Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift create mode 100644 Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift create mode 100644 Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanDebugProbe.swift create mode 100644 Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift create mode 100644 Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 2f1b993b9..499be19ea 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1064,7 +1064,7 @@ extension UsageMenuCardView.Model { } if labels.showsTertiary, let opus = snapshot.tertiary { var tertiaryDetailText: String? - if input.provider == .alibaba, + if input.provider == .alibaba || input.provider == .alibabatokenplan, let detail = opus.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1206,9 +1206,10 @@ extension UsageMenuCardView.Model { let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) primaryDetailLeft = "\(remaining) of \(total) credits left" } - if input.provider == .alibaba || input.provider == .mistral || input.provider == .manus, - let detail = primary.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if input.provider == .alibaba || input.provider == .alibabatokenplan || input.provider == .mistral || input + .provider == .manus, + let detail = primary.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { primaryDetailText = detail if input.provider == .manus { primaryResetText = nil } @@ -1330,7 +1331,7 @@ extension UsageMenuCardView.Model { pacePercent: nil, paceOnTop: true) } - if input.provider == .alibaba, + if input.provider == .alibaba || input.provider == .alibabatokenplan, let detail = weekly.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift new file mode 100644 index 000000000..ad62bee57 --- /dev/null +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift @@ -0,0 +1,93 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { + let id: UsageProvider = .alibabatokenplan + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.alibabaTokenPlanCookieSource + _ = settings.alibabaTokenPlanCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return .alibabaTokenPlan(context.settings.alibabaTokenPlanSettingsSnapshot()) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.alibabaTokenPlanCookieSource.rawValue }, + set: { raw in + context.settings.alibabaTokenPlanCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.alibabaTokenPlanCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies from Bailian.", + manual: "Paste a Cookie header from bailian.console.aliyun.com.", + off: "Alibaba Token Plan cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "alibaba-token-plan-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies from Bailian.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.load(provider: .alibabatokenplan) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "alibaba-token-plan-cookie", + title: "Cookie header", + subtitle: "", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.alibabaTokenPlanCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "alibaba-token-plan-open-dashboard", + title: "Open Token Plan", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(AlibabaTokenPlanUsageFetcher.dashboardURL) + }), + ], + isVisible: { + context.settings.alibabaTokenPlanCookieSource == .manual + }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift new file mode 100644 index 000000000..6f2ed187b --- /dev/null +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift @@ -0,0 +1,30 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var alibabaTokenPlanCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .alibabatokenplan)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .alibabatokenplan) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .alibabatokenplan, field: "cookieHeader", value: newValue) + } + } + + var alibabaTokenPlanCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .alibabatokenplan, fallback: .auto) } + set { + self.updateProviderConfig(provider: .alibabatokenplan) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .alibabatokenplan, field: "cookieSource", value: newValue.rawValue) + } + } + + func alibabaTokenPlanSettingsSnapshot() -> ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings { + ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: self.alibabaTokenPlanCookieSource, + manualCookieHeader: self.alibabaTokenPlanCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 54fc82c6e..22bec3dff 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -21,6 +21,7 @@ enum ProviderImplementationRegistry { case .opencode: OpenCodeProviderImplementation() case .opencodego: OpenCodeGoProviderImplementation() case .alibaba: AlibabaCodingPlanProviderImplementation() + case .alibabatokenplan: AlibabaTokenPlanProviderImplementation() case .factory: FactoryProviderImplementation() case .gemini: GeminiProviderImplementation() case .antigravity: AntigravityProviderImplementation() diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d798152d4..ae9d4f7bf 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -933,6 +933,7 @@ extension UsageStore { await AugmentStatusProbe.latestDumps() } + // swiftlint:disable:next cyclomatic_complexity function_body_length func debugLog(for provider: UsageProvider) async -> String { if let cached = self.probeLogs[provider], !cached.isEmpty { return cached @@ -957,6 +958,8 @@ extension UsageStore { let ampCookieHeader = self.settings.ampCookieHeader let ollamaCookieSource = self.settings.ollamaCookieSource let ollamaCookieHeader = self.settings.ollamaCookieHeader + let alibabaTokenPlanCookieSource = self.settings.alibabaTokenPlanCookieSource + let alibabaTokenPlanCookieHeader = self.settings.alibabaTokenPlanCookieHeader let processEnvironment = self.environmentBase let openAIDebugContext = self.openAIAPIKeyDebugContext(processEnvironment: processEnvironment) let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment) @@ -978,6 +981,7 @@ extension UsageStore { .antigravity: "Antigravity debug log not yet implemented", .opencode: "OpenCode debug log not yet implemented", .alibaba: "Alibaba Coding Plan debug log not yet implemented", + .alibabatokenplan: "Alibaba Token Plan debug log not yet implemented", .factory: "Droid debug log not yet implemented", .copilot: "Copilot debug log not yet implemented", .manus: "Manus debug log not yet implemented", @@ -1043,6 +1047,12 @@ extension UsageStore { let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "ALIBABA_CODING_PLAN_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .alibabatokenplan: + return await Self.debugAlibabaTokenPlanLog( + browserDetection: browserDetection, + cookieSource: alibabaTokenPlanCookieSource, + cookieHeader: alibabaTokenPlanCookieHeader, + environment: processEnvironment) case .augment: return await Self.debugAugmentLog() case .amp: @@ -1380,6 +1390,21 @@ extension UsageStore { } } + private static func debugAlibabaTokenPlanLog( + browserDetection: BrowserDetection, + cookieSource: ProviderCookieSource, + cookieHeader: String, + environment: [String: String]) async -> String + { + await runWithTimeout(seconds: 20) { + await AlibabaTokenPlanDebugProbe.debugLog( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader, + environment: environment, + browserDetection: browserDetection) + } + } + private func detectVersions() { let implementations = ProviderCatalog.all let browserDetection = self.browserDetection diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index e7d3a17c3..a4fbfc718 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -172,6 +172,11 @@ struct TokenAccountCLIContext { cookieSource: cookieSource, manualCookieHeader: cookieHeader, apiRegion: self.resolveAlibabaCodingPlanRegion(config))) + case .alibabatokenplan: + return self.makeSnapshot( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .factory: return self.makeSnapshot( factory: ProviderSettingsSnapshot.FactoryProviderSettings( @@ -249,6 +254,7 @@ struct TokenAccountCLIContext { opencode: ProviderSettingsSnapshot.OpenCodeProviderSettings? = nil, opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings? = nil, alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? = nil, + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings? = nil, factory: ProviderSettingsSnapshot.FactoryProviderSettings? = nil, minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? = nil, manus: ProviderSettingsSnapshot.ManusProviderSettings? = nil, @@ -273,6 +279,7 @@ struct TokenAccountCLIContext { opencode: opencode, opencodego: opencodego, alibaba: alibaba, + alibabaTokenPlan: alibabaTokenPlan, factory: factory, minimax: minimax, manus: manus, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 522698f05..61224669a 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "e478cccb0110e8ad" + static let value = "026e58ca059e8da5" } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift new file mode 100644 index 000000000..1c12ab63c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift @@ -0,0 +1,152 @@ +import Foundation + +struct AlibabaTokenPlanCookieHeaders: Sendable { + private static let cachedAPIHeaderName = "__codexbar_alibaba_token_plan_api" + private static let cachedDashboardHeaderName = "__codexbar_alibaba_token_plan_dashboard" + + let apiCookieHeader: String + let dashboardCookieHeader: String + + init(apiCookieHeader: String, dashboardCookieHeader: String) { + self.apiCookieHeader = apiCookieHeader + self.dashboardCookieHeader = dashboardCookieHeader + } + + init?(singleHeader raw: String?) { + guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } + self.apiCookieHeader = normalized + self.dashboardCookieHeader = normalized + } + + init?(cachedHeader raw: String?) { + var valuesByName: [String: String] = [:] + for pair in CookieHeaderNormalizer.pairs(from: raw ?? "") { + valuesByName[pair.name] = pair.value + } + if let encodedAPI = valuesByName[Self.cachedAPIHeaderName], + let encodedDashboard = valuesByName[Self.cachedDashboardHeaderName], + let apiHeader = Self.decodeCachedHeader(encodedAPI), + let dashboardHeader = Self.decodeCachedHeader(encodedDashboard), + let normalizedAPI = CookieHeaderNormalizer.normalize(apiHeader), + let normalizedDashboard = CookieHeaderNormalizer.normalize(dashboardHeader) + { + self.init(apiCookieHeader: normalizedAPI, dashboardCookieHeader: normalizedDashboard) + return + } + + self.init(singleHeader: raw) + } + + var cacheCookieHeader: String { + [ + "\(Self.cachedAPIHeaderName)=\(Self.encodeCachedHeader(self.apiCookieHeader))", + "\(Self.cachedDashboardHeaderName)=\(Self.encodeCachedHeader(self.dashboardCookieHeader))", + ].joined(separator: "; ") + } + + var apiCookieNames: [String] { + Self.cookieNames(from: self.apiCookieHeader) + } + + var dashboardCookieNames: [String] { + Self.cookieNames(from: self.dashboardCookieHeader) + } + + func hasCookie(named name: String) -> Bool { + Self.cookieNames(from: self.apiCookieHeader).contains(name) || + Self.cookieNames(from: self.dashboardCookieHeader).contains(name) + } + + private static func cookieNames(from header: String) -> [String] { + CookieHeaderNormalizer.pairs(from: header) + .map(\.name) + .filter { !$0.isEmpty } + .uniquedSorted() + } + + private static func encodeCachedHeader(_ header: String) -> String { + Data(header.utf8).base64EncodedString() + } + + private static func decodeCachedHeader(_ encoded: String) -> String? { + guard let data = Data(base64Encoded: encoded) else { return nil } + return String(data: data, encoding: .utf8) + } +} + +enum AlibabaTokenPlanCookieHeader { + static func headers(from cookies: [HTTPCookie]) -> AlibabaTokenPlanCookieHeaders? { + guard let apiHeader = self.header(from: cookies, targetURL: AlibabaTokenPlanUsageFetcher.defaultQuotaURL), + let dashboardHeader = self.header(from: cookies, targetURL: AlibabaTokenPlanUsageFetcher.dashboardURL) + else { + return nil + } + return AlibabaTokenPlanCookieHeaders(apiCookieHeader: apiHeader, dashboardCookieHeader: dashboardHeader) + } + + static func header(from cookies: [HTTPCookie], targetURL: URL) -> String? { + var byName: [String: HTTPCookie] = [:] + for cookie in cookies { + guard !cookie.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + guard !cookie.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + if let expiry = cookie.expiresDate, expiry < Date() { continue } + guard self.matchesRequestURL(cookie: cookie, url: targetURL) else { continue } + + if let existing = byName[cookie.name] { + if self.cookieSortKey(for: cookie) >= self.cookieSortKey(for: existing) { + byName[cookie.name] = cookie + } + } else { + byName[cookie.name] = cookie + } + } + + guard !byName.isEmpty else { return nil } + return byName.keys.sorted().compactMap { name in + guard let cookie = byName[name] else { return nil } + return "\(cookie.name)=\(cookie.value)" + }.joined(separator: "; ") + } + + private static func matchesRequestURL(cookie: HTTPCookie, url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + guard !normalizedDomain.isEmpty else { return false } + guard host == normalizedDomain || host.hasSuffix(".\(normalizedDomain)") else { return false } + + let cookiePath = cookie.path.isEmpty ? "/" : cookie.path + let requestPath = url.path.isEmpty ? "/" : url.path + if requestPath == cookiePath { + return true + } + guard requestPath.hasPrefix(cookiePath) else { return false } + guard cookiePath != "/" else { return true } + if cookiePath.hasSuffix("/") { + return true + } + guard + let boundaryIndex = requestPath.index( + requestPath.startIndex, + offsetBy: cookiePath.count, + limitedBy: requestPath.endIndex), + boundaryIndex < requestPath.endIndex + else { + return true + } + return requestPath[boundaryIndex] == "/" + } + + private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) { + let pathLength = cookie.path.count + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let domainLength = normalizedDomain.count + let expiry = cookie.expiresDate ?? .distantPast + return (pathLength, domainLength, expiry) + } +} + +extension [String] { + fileprivate func uniquedSorted() -> [String] { + Array(Set(self)).sorted() + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanDebugProbe.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanDebugProbe.swift new file mode 100644 index 000000000..e12498940 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanDebugProbe.swift @@ -0,0 +1,133 @@ +import Foundation + +public enum AlibabaTokenPlanDebugProbe { + public static func debugLog( + cookieSource: ProviderCookieSource, + manualCookieHeader: String, + environment: [String: String], + browserDetection: BrowserDetection) async -> String + { + var lines: [String] = [ + "Alibaba Token Plan debug", + "Cookie source: \(cookieSource.rawValue)", + ] + + do { + let headers = try self.resolveCookieHeaders( + cookieSource: cookieSource, + manualCookieHeader: manualCookieHeader, + environment: environment, + browserDetection: browserDetection, + lines: &lines) + lines.append("API cookie names: \(self.namesDescription(headers.apiCookieNames))") + lines.append("Dashboard cookie names: \(self.namesDescription(headers.dashboardCookieNames))") + lines.append("Has sec_token cookie: \(headers.hasCookie(named: "sec_token") ? "yes" : "no")") + lines.append("Has login ticket: \(headers.hasCookie(named: "login_aliyunid_ticket") ? "yes" : "no")") + lines.append("Has account cookie: \(self.hasAlibabaAccountCookie(headers) ? "yes" : "no")") + + do { + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: headers.apiCookieHeader, + dashboardCookieHeader: headers.dashboardCookieHeader, + environment: environment) + lines.append("Fetch: success") + lines.append("Plan present: \(snapshot.planName == nil ? "no" : "yes")") + lines.append("Quota total present: \(snapshot.totalQuota == nil ? "no" : "yes")") + lines.append("Quota used present: \(snapshot.usedQuota == nil ? "no" : "yes")") + lines.append("Quota remaining present: \(snapshot.remainingQuota == nil ? "no" : "yes")") + lines.append("Reset present: \(snapshot.resetsAt == nil ? "no" : "yes")") + } catch { + lines.append("Fetch: failed") + lines.append("Fetch error: \(type(of: error))") + lines.append("Fetch message: \(error.localizedDescription)") + } + } catch { + lines.append("Cookie resolution: failed") + lines.append("Cookie error: \(type(of: error))") + lines.append("Cookie message: \(error.localizedDescription)") + } + + return lines.joined(separator: "\n") + } + + private static func resolveCookieHeaders( + cookieSource: ProviderCookieSource, + manualCookieHeader: String, + environment: [String: String], + browserDetection: BrowserDetection, + lines: inout [String]) throws -> AlibabaTokenPlanCookieHeaders + { + if cookieSource == .manual { + let hasManualCookie = !manualCookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + lines.append("Manual cookie configured: \(hasManualCookie ? "yes" : "no")") + guard let headers = AlibabaTokenPlanCookieHeaders(singleHeader: manualCookieHeader) else { + throw AlibabaTokenPlanSettingsError.invalidCookie + } + lines.append("Cookie source selected: manual") + return headers + } + + if let envCookie = AlibabaTokenPlanSettingsReader.cookieHeader(environment: environment), + let headers = AlibabaTokenPlanCookieHeaders(singleHeader: envCookie) + { + lines.append("Environment cookie configured: yes") + lines.append("Cookie source selected: environment") + return headers + } + lines.append("Environment cookie configured: no") + + #if os(macOS) + if let cached = CookieHeaderCache.load(provider: .alibabatokenplan) { + lines.append("Cached browser cookie: yes") + lines.append("Cached browser source: \(cached.sourceLabel)") + if let headers = AlibabaTokenPlanCookieHeaders(cachedHeader: cached.cookieHeader) { + lines.append("Cookie source selected: browser-cache") + return headers + } + lines.append("Cached browser cookie parseable: no") + } else { + lines.append("Cached browser cookie: no") + } + + var importLines: [String] = [] + let session = try AlibabaCodingPlanCookieImporter.importSession( + browserDetection: browserDetection, + logger: { importLines.append($0) }) + lines.append("Browser import source: \(session.sourceLabel)") + if importLines.isEmpty { + lines.append("Browser import log: empty") + } else { + lines.append("Browser import log:") + lines.append(contentsOf: importLines.map { " \($0)" }) + } + + let rawNames = session.cookies.map(\.name).filter { !$0.isEmpty }.uniquedSorted() + lines.append("Raw imported cookie names: \(self.namesDescription(rawNames))") + + guard let headers = AlibabaTokenPlanCookieHeader.headers(from: session.cookies) else { + throw AlibabaTokenPlanSettingsError.missingCookie( + details: "No Alibaba Token Plan browser cookies were available after import.") + } + lines.append("Cookie source selected: browser-import") + return headers + #else + throw AlibabaTokenPlanSettingsError.missingCookie(details: "Browser cookie import is only available on macOS.") + #endif + } + + private static func hasAlibabaAccountCookie(_ headers: AlibabaTokenPlanCookieHeaders) -> Bool { + headers.hasCookie(named: "login_aliyunid_pk") || + headers.hasCookie(named: "login_current_pk") || + headers.hasCookie(named: "login_aliyunid") + } + + private static func namesDescription(_ names: [String]) -> String { + names.isEmpty ? "none" : names.joined(separator: ",") + } +} + +extension [String] { + fileprivate func uniquedSorted() -> [String] { + Array(Set(self)).sorted() + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift new file mode 100644 index 000000000..8e5e5aa63 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -0,0 +1,254 @@ +import CodexBarMacroSupport +import Foundation + +#if os(macOS) +import SweetCookieKit +#endif + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum AlibabaTokenPlanProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + #if os(macOS) + let browserOrder: BrowserCookieImportOrder = [ + .chrome, + .chromeBeta, + .brave, + .edge, + .arc, + .firefox, + .safari, + ] + #else + let browserOrder: BrowserCookieImportOrder? = nil + #endif + + return ProviderDescriptor( + id: .alibabatokenplan, + metadata: ProviderMetadata( + id: .alibabatokenplan, + displayName: "Alibaba Token Plan", + sessionLabel: "Credits", + weeklyLabel: "Usage", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Alibaba Token Plan usage", + cliName: "alibaba-token-plan", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: browserOrder, + dashboardURL: AlibabaTokenPlanUsageFetcher.dashboardURL.absoluteString, + statusPageURL: nil, + statusLinkURL: "https://status.aliyun.com"), + branding: ProviderBranding( + iconStyle: .alibaba, + iconResourceName: "ProviderIcon-alibaba", + color: ProviderColor(red: 1.0, green: 106 / 255, blue: 0)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Alibaba Token Plan cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), + cli: ProviderCLIConfig( + name: "alibaba-token-plan", + aliases: ["alibaba-token", "bailian-token-plan"], + versionDetector: nil)) + } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + guard context.settings?.alibabaTokenPlan?.cookieSource != .off else { return [] } + switch context.sourceMode { + case .auto, .web: + return [AlibabaTokenPlanWebFetchStrategy()] + case .api, .cli, .oauth: + return [] + } + } +} + +struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { + private static let log = CodexBarLog.logger("alibaba-token-plan") + + let id: String = "alibaba-token-plan.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.alibabaTokenPlan?.cookieSource != .off else { return false } + + if AlibabaTokenPlanSettingsReader.cookieHeader(environment: context.env) != nil { + return true + } + + if let settings = context.settings?.alibabaTokenPlan, + settings.cookieSource == .manual + { + return CookieHeaderNormalizer.normalize(settings.manualCookieHeader) != nil + } + + #if os(macOS) + if let cached = CookieHeaderCache.load(provider: .alibabatokenplan), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.alibabaTokenPlan?.cookieSource ?? .auto + let cookieHeaders = try Self.resolveCookieHeaders(context: context, allowCached: true) + do { + let usage = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: cookieHeaders.apiCookieHeader, + dashboardCookieHeader: cookieHeaders.dashboardCookieHeader, + environment: context.env) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") + } catch let error as AlibabaTokenPlanUsageError + where error.isCredentialFailure && cookieSource != .manual + { + #if os(macOS) + CookieHeaderCache.clear(provider: .alibabatokenplan) + let refreshedHeaders = try Self.resolveCookieHeaders(context: context, allowCached: false) + let usage = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: refreshedHeaders.apiCookieHeader, + dashboardCookieHeader: refreshedHeaders.dashboardCookieHeader, + environment: context.env) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") + #else + throw error + #endif + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { + try self.resolveCookieHeaders(context: context, allowCached: allowCached).apiCookieHeader + } + + static func resolveCookieHeaders( + context: ProviderFetchContext, + allowCached: Bool) throws -> AlibabaTokenPlanCookieHeaders + { + if let settings = context.settings?.alibabaTokenPlan, + settings.cookieSource == .manual + { + guard let headers = AlibabaTokenPlanCookieHeaders(singleHeader: settings.manualCookieHeader) else { + self.log.warning("Alibaba Token Plan manual cookie header is invalid") + throw AlibabaTokenPlanSettingsError.invalidCookie + } + Self.log.info( + "Alibaba Token Plan using manual cookie header", + metadata: [ + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + ]) + return headers + } + + if let envCookie = AlibabaTokenPlanSettingsReader.cookieHeader(environment: context.env), + let headers = AlibabaTokenPlanCookieHeaders(singleHeader: envCookie) + { + Self.log.info( + "Alibaba Token Plan using environment cookie header", + metadata: [ + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + ]) + return headers + } + + #if os(macOS) + if allowCached, + let cached = CookieHeaderCache.load(provider: .alibabatokenplan), + let headers = AlibabaTokenPlanCookieHeaders(cachedHeader: cached.cookieHeader) + { + Self.log.info( + "Alibaba Token Plan using cached browser cookie header", + metadata: [ + "source": cached.sourceLabel, + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + ]) + return headers + } + + do { + var importLog: [String] = [] + let session = try AlibabaCodingPlanCookieImporter.importSession( + browserDetection: context.browserDetection, + logger: { importLog.append($0) }) + let rawCookieNames = session.cookies.map(\.name).filter { !$0.isEmpty }.uniquedSorted() + guard let headers = AlibabaTokenPlanCookieHeader.headers(from: session.cookies) else { + Self.log.warning( + "Alibaba Token Plan browser cookie header was empty", + metadata: [ + "source": session.sourceLabel, + "rawCookieNames": rawCookieNames.joined(separator: ","), + ]) + throw AlibabaTokenPlanSettingsError.missingCookie( + details: "No Alibaba Token Plan browser cookies were available after import.") + } + CookieHeaderCache.store( + provider: .alibabatokenplan, + cookieHeader: headers.cacheCookieHeader, + sourceLabel: session.sourceLabel) + Self.log.info( + "Alibaba Token Plan imported browser cookies", + metadata: [ + "source": session.sourceLabel, + "rawCookieNames": rawCookieNames.joined(separator: ","), + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + "importLogLines": "\(importLog.count)", + ]) + return headers + } catch { + Self.log.warning( + "Alibaba Token Plan cookie resolution failed", + metadata: ["error": error.localizedDescription]) + throw AlibabaTokenPlanSettingsError.missingCookie(details: Self.missingCookieDetails(from: error)) + } + #else + throw AlibabaTokenPlanSettingsError.missingCookie() + #endif + } + + private static func missingCookieDetails(from error: Error) -> String? { + if case let AlibabaCodingPlanSettingsError.missingCookie(details) = error { + return details + } + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? nil : message + } +} + +extension [String] { + fileprivate func uniquedSorted() -> [String] { + Array(Set(self)).sorted() + } +} + +extension AlibabaTokenPlanUsageError { + fileprivate var isCredentialFailure: Bool { + switch self { + case .loginRequired, .invalidCredentials: + true + case .apiError, .networkError, .parseFailed: + false + } + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift new file mode 100644 index 000000000..ba7c5c3a3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift @@ -0,0 +1,61 @@ +import Foundation + +public struct AlibabaTokenPlanSettingsReader: Sendable { + public static let cookieHeaderKey = "ALIBABA_TOKEN_PLAN_COOKIE" + public static let hostKey = "ALIBABA_TOKEN_PLAN_HOST" + public static let quotaURLKey = "ALIBABA_TOKEN_PLAN_QUOTA_URL" + + public static func cookieHeader( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.cookieHeaderKey]) + } + + public static func hostOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.hostKey]) + } + + public static func quotaURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? + { + guard let raw = self.cleaned(environment[self.quotaURLKey]) else { return nil } + if let url = URL(string: raw), url.scheme != nil { + return url + } + return URL(string: "https://\(raw)") + } + + 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 + } +} + +public enum AlibabaTokenPlanSettingsError: LocalizedError, Sendable { + case missingCookie(details: String? = nil) + case invalidCookie + + public var errorDescription: String? { + switch self { + case let .missingCookie(details): + let base = "No Alibaba Token Plan session cookies found in browsers. " + + "Sign in to Bailian in Chrome, allow CodexBar to access Chrome Safe Storage in Keychain Access, " + + "or paste a manual Cookie header." + guard let details, !details.isEmpty else { return base } + return "\(base) \(details)" + case .invalidCookie: + return "Alibaba Token Plan cookie header is invalid." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift new file mode 100644 index 000000000..23b55c510 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -0,0 +1,885 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { + case loginRequired + case invalidCredentials + case apiError(String) + case networkError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .loginRequired: + "Alibaba Token Plan login required." + case .invalidCredentials: + "Alibaba Token Plan credentials are invalid." + case let .apiError(message): + "Alibaba Token Plan API error: \(message)" + case let .networkError(message): + "Alibaba Token Plan network error: \(message)" + case let .parseFailed(message): + "Could not parse Alibaba Token Plan usage: \(message)" + } + } +} + +public struct AlibabaTokenPlanUsageFetcher: Sendable { + private static let log = CodexBarLog.logger("alibaba-token-plan") + private static let gatewayBaseURLString = "https://bailian-cs.console.aliyun.com" + private static let dashboardOriginURLString = "https://bailian.console.aliyun.com" + private static let currentRegionID = "cn-beijing" + private static let apiName = "zeldaEasy.bailian-commerce.tokenPlan.queryTokenPlanInstanceInfo" + private static let tokenPlanCommodityCode = "sfm_tokenplanteams_dp_cn" + private static let browserLikeUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + private static let safariLikeUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15" + + public static var dashboardURL: URL { + URL(string: "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan")! + } + + public static func fetchUsage( + cookieHeader: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) async throws -> AlibabaTokenPlanUsageSnapshot + { + guard let headers = AlibabaTokenPlanCookieHeaders(singleHeader: cookieHeader) else { + throw AlibabaTokenPlanSettingsError.invalidCookie + } + return try await self.fetchUsage( + apiCookieHeader: headers.apiCookieHeader, + dashboardCookieHeader: headers.dashboardCookieHeader, + environment: environment, + now: now) + } + + static func fetchUsage( + apiCookieHeader: String, + dashboardCookieHeader: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + session overrideSession: URLSession? = nil) async throws -> AlibabaTokenPlanUsageSnapshot + { + guard let normalizedAPIHeader = CookieHeaderNormalizer.normalize(apiCookieHeader), + let normalizedDashboardHeader = CookieHeaderNormalizer.normalize(dashboardCookieHeader) + else { + throw AlibabaTokenPlanSettingsError.invalidCookie + } + + let url = self.resolveQuotaURL(environment: environment) + let secToken = await self.resolveSECToken( + dashboardCookieHeader: normalizedDashboardHeader, + apiCookieHeader: normalizedAPIHeader, + environment: environment) + let anonymousID = self.extractCookieValue(name: "cna", from: normalizedAPIHeader) + Self.log.info( + "Fetching Alibaba Token Plan usage", + metadata: [ + "apiHost": url.host ?? "unknown", + "apiCookieNames": self.cookieNamesDescription(from: normalizedAPIHeader), + "dashboardCookieNames": self.cookieNamesDescription(from: normalizedDashboardHeader), + "hasAnonymousID": anonymousID == nil ? "0" : "1", + "hasCSRF": self.hasCSRF(in: normalizedAPIHeader) ? "1" : "0", + "secTokenSource": secToken == nil ? "missing" : "resolved", + ]) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.httpBody = self.queryTokenPlanRequestBody(secToken: secToken, anonymousID: anonymousID) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue(normalizedAPIHeader, forHTTPHeaderField: "Cookie") + if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: normalizedAPIHeader) ?? + self.extractCookieValue(name: "csrf", from: normalizedAPIHeader) + { + request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") + request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") + } + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue(Self.dashboardOriginURLString, forHTTPHeaderField: "Origin") + request.setValue(Self.dashboardURL.absoluteString, forHTTPHeaderField: "Referer") + + let data: Data + let response: URLResponse + let redirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedAPIHeader) + let session = overrideSession ?? URLSession( + configuration: .default, + delegate: redirectDiagnostics, + delegateQueue: nil) + do { + (data, response) = try await session.data(for: request) + } catch { + Self.log.error( + "Alibaba Token Plan request failed", + metadata: [ + "apiHost": url.host ?? "unknown", + "error": error.localizedDescription, + ]) + throw AlibabaTokenPlanUsageError.networkError(error.localizedDescription) + } + if !redirectDiagnostics.redirects.isEmpty { + Self.log.info( + "Alibaba Token Plan redirects", + metadata: [ + "count": "\(redirectDiagnostics.redirects.count)", + "items": redirectDiagnostics.redirects.joined(separator: " | "), + ]) + } + guard let httpResponse = response as? HTTPURLResponse else { + Self.log.error("Alibaba Token Plan response was not HTTP") + throw AlibabaTokenPlanUsageError.networkError("Invalid response") + } + Self.log.info( + "Alibaba Token Plan HTTP response", + metadata: [ + "status": "\(httpResponse.statusCode)", + "bodyBytes": "\(data.count)", + "contentType": httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "none", + ]) + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw AlibabaTokenPlanUsageError.loginRequired + } + Self.log.error("Alibaba Token Plan returned HTTP \(httpResponse.statusCode)") + throw AlibabaTokenPlanUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + + return try self.parseUsageSnapshot(from: data, now: now) + } + + static func resolveQuotaURL(environment: [String: String]) -> URL { + if let override = AlibabaTokenPlanSettingsReader.quotaURL(environment: environment) { + return override + } + if let host = AlibabaTokenPlanSettingsReader.hostOverride(environment: environment), + let hostURL = self.quotaURL(from: host) + { + return hostURL + } + return self.defaultQuotaURL + } + + static var defaultQuotaURL: URL { + var components = URLComponents(string: Self.gatewayBaseURLString)! + components.path = "/data/api.json" + components.queryItems = [ + URLQueryItem(name: "action", value: "BroadScopeAspnGateway"), + URLQueryItem(name: "product", value: "sfm_bailian"), + URLQueryItem(name: "api", value: Self.apiName), + URLQueryItem(name: "_v", value: "undefined"), + ] + return components.url! + } + + static func parseUsageSnapshot(from data: Data, now: Date = Date()) throws -> AlibabaTokenPlanUsageSnapshot { + guard !data.isEmpty else { + throw AlibabaTokenPlanUsageError.parseFailed("Empty response body") + } + + let object = try JSONSerialization.jsonObject(with: data, options: []) + let expanded = self.expandedJSON(object) + guard let dictionary = expanded as? [String: Any] else { + throw AlibabaTokenPlanUsageError.parseFailed("Unexpected payload") + } + + try self.throwIfErrorPayload(dictionary) + + let instance = self.findTokenPlanInstance(in: dictionary) + let planName = self.findPlanName(in: instance ?? [:]) ?? self.findPlanName(in: dictionary) + let quotaSource = self.findQuotaInfo(in: instance ?? [:]) ?? self.findQuotaInfo(in: dictionary) + let used = quotaSource.flatMap { self.anyDouble(for: Self.usedQuotaKeys, in: $0) } + let total = quotaSource.flatMap { self.anyDouble(for: Self.totalQuotaKeys, in: $0) } + let remaining = quotaSource.flatMap { self.anyDouble(for: Self.remainingQuotaKeys, in: $0) } + let resetsAt = self.findResetDate(in: instance ?? [:]) ?? self.findResetDate(in: dictionary) + + if planName == nil, total == nil, used == nil, remaining == nil { + let diagnostics = self.payloadDiagnostics(payload: dictionary) + Self.log.error("Alibaba Token Plan payload missing expected fields: \(diagnostics)") + throw AlibabaTokenPlanUsageError.parseFailed("Missing token plan data (\(diagnostics))") + } + + return AlibabaTokenPlanUsageSnapshot( + planName: planName, + usedQuota: used, + totalQuota: total, + remainingQuota: remaining, + resetsAt: resetsAt, + updatedAt: now) + } + + private static func queryTokenPlanRequestBody(secToken: String?, anonymousID: String?) -> Data { + let traceID = UUID().uuidString.lowercased() + var cornerstoneParam: [String: Any] = [ + "feTraceId": traceID, + "feURL": Self.dashboardURL.absoluteString, + "protocol": "V2", + "console": "ONE_CONSOLE", + "productCode": "p_efm", + "domain": "bailian.console.aliyun.com", + "consoleSite": "BAILIAN_ALIYUN", + "userNickName": "", + "userPrincipalName": "", + "xsp_lang": "zh-CN", + ] + if let anonymousID, !anonymousID.isEmpty { + cornerstoneParam["X-Anonymous-Id"] = anonymousID + } + + let paramsObject: [String: Any] = [ + "Api": Self.apiName, + "V": "1.0", + "Data": [ + "queryTokenPlanInstanceInfoRequest": [ + "commodityCode": Self.tokenPlanCommodityCode, + "onlyLatestOne": true, + ], + "cornerstoneParam": cornerstoneParam, + ], + ] + + guard let paramsData = try? JSONSerialization.data(withJSONObject: paramsObject, options: []), + let paramsString = String(data: paramsData, encoding: .utf8) + else { + return Data() + } + + var components = URLComponents() + var queryItems = [ + URLQueryItem(name: "params", value: paramsString), + URLQueryItem(name: "region", value: Self.currentRegionID), + ] + if let secToken, !secToken.isEmpty { + queryItems.append(URLQueryItem(name: "sec_token", value: secToken)) + } + components.queryItems = queryItems + return Data((components.percentEncodedQuery ?? "").utf8) + } + + private static func resolveSECToken( + dashboardCookieHeader: String, + apiCookieHeader: String, + environment: [String: String]) async -> String? + { + let cookieSECToken = self.extractCookieValue(name: "sec_token", from: dashboardCookieHeader) ?? + self.extractCookieValue(name: "sec_token", from: apiCookieHeader) + var request = URLRequest(url: self.dashboardURL(environment: environment)) + request.httpMethod = "GET" + request.timeoutInterval = 10 + request.setValue(dashboardCookieHeader, forHTTPHeaderField: "Cookie") + request.setValue(Self.safariLikeUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue( + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + forHTTPHeaderField: "Accept") + + if let (data, response) = try? await URLSession.shared.data(for: request), + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let html = String(data: data, encoding: .utf8), + let token = self.extractSECToken(from: html) + { + Self.log.info( + "Resolved Alibaba Token Plan sec_token from dashboard HTML", + metadata: [ + "dashboardHost": request.url?.host ?? "unknown", + "htmlBytes": "\(data.count)", + ]) + return token + } + + if let cookieSECToken, !cookieSECToken.isEmpty { + Self.log.info("Resolved Alibaba Token Plan sec_token from cookies") + return cookieSECToken + } + + Self.log.info( + "Alibaba Token Plan sec_token missing; continuing with cookie-only request", + metadata: [ + "dashboardCookieNames": self.cookieNamesDescription(from: dashboardCookieHeader), + "apiCookieNames": self.cookieNamesDescription(from: apiCookieHeader), + ]) + return nil + } + + private static func quotaURL(from rawHost: String) -> URL? { + let cleaned = AlibabaTokenPlanSettingsReader.cleaned(rawHost) + guard let cleaned else { return nil } + let base = URL(string: cleaned)?.scheme == nil ? URL(string: "https://\(cleaned)") : URL(string: cleaned) + guard let base else { return nil } + var components = URLComponents(url: base, resolvingAgainstBaseURL: false) + let defaultComponents = URLComponents(url: Self.defaultQuotaURL, resolvingAgainstBaseURL: false) + components?.path = "/data/api.json" + components?.queryItems = defaultComponents?.queryItems + return components?.url + } + + private static func dashboardURL(environment: [String: String]) -> URL { + if let host = AlibabaTokenPlanSettingsReader.hostOverride(environment: environment), + let base = URL(string: host)?.scheme == nil ? URL(string: "https://\(host)") : URL(string: host), + var components = URLComponents(url: base, resolvingAgainstBaseURL: false), + let dashboardComponents = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) + { + components.path = dashboardComponents.path + components.percentEncodedQuery = dashboardComponents.percentEncodedQuery + components.fragment = dashboardComponents.fragment + return components.url ?? self.dashboardURL + } + return Self.dashboardURL + } + + private final class RedirectDiagnostics: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let cookieHeader: String + var redirects: [String] = [] + + init(cookieHeader: String) { + self.cookieHeader = cookieHeader + } + + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + let from = AlibabaTokenPlanUsageFetcher.redactedURLDescription(response.url) + let to = AlibabaTokenPlanUsageFetcher.redactedURLDescription(request.url) + self.redirects.append("\(response.statusCode) \(from) -> \(to)") + + completionHandler(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: request, + cookieHeader: self.cookieHeader)) + } + } + + private static func redactedURLDescription(_ url: URL?) -> String { + guard let url else { return "unknown" } + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.query = nil + components?.fragment = nil + return components?.string ?? "\(url.scheme ?? "unknown")://\(url.host ?? "unknown")" + } + + static func redirectedRequest( + response: HTTPURLResponse, + request: URLRequest, + cookieHeader: String) -> URLRequest? + { + guard request.url?.scheme?.lowercased() == "https" else { + return nil + } + + var updated = request + if self.shouldForwardRedirectCookies(from: response.url, to: request.url) { + updated.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } else { + updated.setValue(nil, forHTTPHeaderField: "Cookie") + } + return updated + } + + private static func shouldForwardRedirectCookies(from sourceURL: URL?, to targetURL: URL?) -> Bool { + guard let sourceHost = sourceURL?.host?.lowercased(), + let targetHost = targetURL?.host?.lowercased() + else { + return false + } + return sourceHost == targetHost + } + + private static func throwIfErrorPayload(_ dictionary: [String: Any]) throws { + if let statusCode = self.findFirstInt(forKeys: ["statusCode", "status_code", "code"], in: dictionary), + statusCode != 0, + statusCode != 200 + { + let message = self.findFirstString( + forKeys: ["statusMessage", "status_msg", "message", "msg"], + in: dictionary) + ?? "status code \(statusCode)" + if statusCode == 401 || statusCode == 403 { + throw AlibabaTokenPlanUsageError.invalidCredentials + } + throw AlibabaTokenPlanUsageError.apiError(message) + } + + let codeText = self.findFirstString(forKeys: ["code", "status", "statusCode"], in: dictionary)?.lowercased() + let messageText = self.findFirstString(forKeys: ["message", "msg", "statusMessage"], in: dictionary)? + .lowercased() + if codeText?.contains("needlogin") == true || + codeText?.contains("login") == true || + messageText?.contains("log in") == true || + messageText?.contains("login") == true + { + throw AlibabaTokenPlanUsageError.loginRequired + } + } + + private static let planNameKeys = [ + "planName", + "plan_name", + "packageName", + "package_name", + "commodityName", + "commodity_name", + "instanceName", + "instance_name", + "displayName", + "display_name", + "name", + "title", + "planType", + "plan_type", + ] + private static let usedQuotaKeys = [ + "usedQuota", + "used_quota", + "usedCredits", + "usedCredit", + "consumedCredits", + "usage", + "used", + "usedAmount", + "consumeAmount", + ] + private static let totalQuotaKeys = [ + "totalQuota", + "total_quota", + "totalCredits", + "totalCredit", + "quota", + "creditLimit", + "creditsTotal", + "monthlyTotalQuota", + "amount", + ] + private static let remainingQuotaKeys = [ + "remainingQuota", + "remainQuota", + "remainingCredits", + "remainingCredit", + "availableCredits", + "balance", + "remaining", + "availableAmount", + "remainAmount", + ] + private static let resetDateKeys = [ + "nextRefreshTime", + "resetTime", + "periodEndTime", + "billingCycleEnd", + "billCycleEndTime", + "expireTime", + "expirationTime", + "endTime", + "validEndTime", + "instanceEndTime", + ] + + private static func findTokenPlanInstance(in payload: [String: Any]) -> [String: Any]? { + if let direct = self.findFirstDictionary( + forKeys: ["tokenPlanInstanceInfo", "token_plan_instance_info", "instanceInfo", "instance_info"], + in: payload) + { + return direct + } + if let infos = self.findFirstArray( + forKeys: ["tokenPlanInstanceInfos", "token_plan_instance_infos", "instanceInfos", "instances"], + in: payload) + { + return infos.compactMap { $0 as? [String: Any] }.max { + self.activeSignalScore(in: $0) < self.activeSignalScore(in: $1) + } + } + return nil + } + + private static func findPlanName(in payload: [String: Any]) -> String? { + self.anyString(for: self.planNameKeys, in: payload) ?? + self.findFirstString(forKeys: self.planNameKeys, in: payload) + } + + private static func findQuotaInfo(in payload: [String: Any]) -> [String: Any]? { + if let direct = self.findFirstDictionary( + forKeys: ["quotaInfo", "quota_info", "tokenPlanQuotaInfo", "token_plan_quota_info"], + in: payload) + { + return direct + } + return self.findFirstDictionary( + matchingAnyKey: Self.usedQuotaKeys + Self.totalQuotaKeys + Self.remainingQuotaKeys, + in: payload) + } + + private static func findResetDate(in payload: [String: Any]) -> Date? { + self.anyDate(for: self.resetDateKeys, in: payload) ?? + self.findFirstDate(forKeys: self.resetDateKeys, in: payload) + } + + private static func payloadDiagnostics(payload: [String: Any]) -> String { + let topKeys = payload.keys.sorted() + let dataDict = self.findFirstDictionary(forKeys: ["data", "successResponse", "success_response"], in: payload) + let dataKeys = dataDict?.keys.sorted() ?? [] + let instance = self.findTokenPlanInstance(in: payload) + let instanceKeys = instance?.keys.sorted() ?? [] + return "topKeys=\(topKeys.joined(separator: ",")) dataKeys=\(dataKeys.joined(separator: ",")) " + + "instanceKeys=\(instanceKeys.joined(separator: ","))" + } + + private static func activeSignalScore(in source: [String: Any]) -> Int { + if let status = self.anyString(for: ["status", "instanceStatus", "state"], in: source)?.uppercased() { + if ["VALID", "ACTIVE", "NORMAL"].contains(status) { + return 3 + } + if ["EXPIRED", "INVALID", "INACTIVE", "DISABLED", "TERMINATED", "STOPPED"].contains(status) { + return -1 + } + } + if let isActive = self.anyBool(for: ["isActive", "active"], in: source) { + return isActive ? 3 : -1 + } + return 0 + } + + private static func findFirstDictionary(forKeys keys: [String], in value: Any) -> [String: Any]? { + if let dict = value as? [String: Any] { + for key in keys { + if let nested = dict[key] as? [String: Any] { + return nested + } + } + for nestedValue in dict.values { + if let nested = self.findFirstDictionary(forKeys: keys, in: nestedValue) { + return nested + } + } + return nil + } + if let array = value as? [Any] { + for item in array { + if let nested = self.findFirstDictionary(forKeys: keys, in: item) { + return nested + } + } + } + return nil + } + + private static func findFirstDictionary(matchingAnyKey keys: [String], in value: Any) -> [String: Any]? { + if let dict = value as? [String: Any] { + if keys.contains(where: { dict[$0] != nil }) { + return dict + } + for nestedValue in dict.values { + if let nested = self.findFirstDictionary(matchingAnyKey: keys, in: nestedValue) { + return nested + } + } + return nil + } + if let array = value as? [Any] { + for item in array { + if let nested = self.findFirstDictionary(matchingAnyKey: keys, in: item) { + return nested + } + } + } + return nil + } + + private static func findFirstArray(forKeys keys: [String], in value: Any) -> [Any]? { + if let dict = value as? [String: Any] { + for key in keys { + if let array = dict[key] as? [Any] { + return array + } + } + for nestedValue in dict.values { + if let found = self.findFirstArray(forKeys: keys, in: nestedValue) { + return found + } + } + return nil + } + if let array = value as? [Any] { + for item in array { + if let found = self.findFirstArray(forKeys: keys, in: item) { + return found + } + } + } + return nil + } + + private static func findFirstString(forKeys keys: [String], in value: Any) -> String? { + if let dict = value as? [String: Any] { + for key in keys { + if let parsed = self.parseString(dict[key]) { + return parsed + } + } + for nestedValue in dict.values { + if let parsed = self.findFirstString(forKeys: keys, in: nestedValue) { + return parsed + } + } + return nil + } + if let array = value as? [Any] { + for item in array { + if let parsed = self.findFirstString(forKeys: keys, in: item) { + return parsed + } + } + } + return nil + } + + private static func findFirstInt(forKeys keys: [String], in value: Any) -> Int? { + if let dict = value as? [String: Any] { + for key in keys { + if let parsed = self.parseInt(dict[key]) { + return parsed + } + } + for nestedValue in dict.values { + if let parsed = self.findFirstInt(forKeys: keys, in: nestedValue) { + return parsed + } + } + return nil + } + if let array = value as? [Any] { + for item in array { + if let parsed = self.findFirstInt(forKeys: keys, in: item) { + return parsed + } + } + } + return nil + } + + private static func findFirstDate(forKeys keys: [String], in value: Any) -> Date? { + if let dict = value as? [String: Any] { + for key in keys { + if let parsed = self.parseDate(dict[key]) { + return parsed + } + } + for nestedValue in dict.values { + if let parsed = self.findFirstDate(forKeys: keys, in: nestedValue) { + return parsed + } + } + return nil + } + if let array = value as? [Any] { + for item in array { + if let parsed = self.findFirstDate(forKeys: keys, in: item) { + return parsed + } + } + } + return nil + } + + private static func expandedJSON(_ value: Any) -> Any { + if let dict = value as? [String: Any] { + var expanded: [String: Any] = [:] + expanded.reserveCapacity(dict.count) + for (key, nested) in dict { + expanded[key] = self.expandedJSON(nested) + } + return expanded + } + if let array = value as? [Any] { + return array.map { self.expandedJSON($0) } + } + if let string = value as? String, + let data = string.data(using: .utf8), + let nested = try? JSONSerialization.jsonObject(with: data, options: []), + nested is [String: Any] || nested is [Any] + { + return self.expandedJSON(nested) + } + return value + } + + private static func anyString(for keys: [String], in dict: [String: Any]) -> String? { + for key in keys { + if let value = self.parseString(dict[key]) { + return value + } + } + return nil + } + + private static func anyDouble(for keys: [String], in dict: [String: Any]) -> Double? { + for key in keys { + if let value = self.parseDouble(dict[key]) { + return value + } + } + return nil + } + + private static func anyDate(for keys: [String], in dict: [String: Any]) -> Date? { + for key in keys { + if let value = self.parseDate(dict[key]) { + return value + } + } + return nil + } + + private static func anyBool(for keys: [String], in dict: [String: Any]) -> Bool? { + for key in keys { + if let value = self.parseBool(dict[key]) { + return value + } + } + return nil + } + + private static func parseInt(_ raw: Any?) -> Int? { + if let value = raw as? Int { return value } + if let value = raw as? Int64 { return Int(value) } + if let value = raw as? Double { return Int(value) } + if let value = raw as? NSNumber { return value.intValue } + if let value = self.parseString(raw) { return Int(value) } + return nil + } + + private static func parseDouble(_ raw: Any?) -> Double? { + if let value = raw as? Double { return value } + if let value = raw as? Int { return Double(value) } + if let value = raw as? Int64 { return Double(value) } + if let value = raw as? NSNumber { return value.doubleValue } + if let value = self.parseString(raw) { + let cleaned = value.replacingOccurrences(of: ",", with: "") + return Double(cleaned) + } + return nil + } + + private static func parseString(_ raw: Any?) -> String? { + guard let value = raw as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func parseDate(_ raw: Any?) -> Date? { + if let intValue = self.parseInt(raw) { + if intValue > 1_000_000_000_000 { + return Date(timeIntervalSince1970: TimeInterval(intValue) / 1000) + } + if intValue > 1_000_000_000 { + return Date(timeIntervalSince1970: TimeInterval(intValue)) + } + } + if let string = self.parseString(raw) { + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: string) { + return date + } + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + for format in ["yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"] { + dateFormatter.dateFormat = format + if let date = dateFormatter.date(from: string) { + return date + } + } + } + return nil + } + + private static func parseBool(_ raw: Any?) -> Bool? { + if let value = raw as? Bool { return value } + if let number = raw as? NSNumber { return number.boolValue } + guard let string = self.parseString(raw)?.lowercased() else { return nil } + switch string { + case "true", "1", "yes", "active", "valid", "normal": + return true + case "false", "0", "no", "inactive", "invalid", "expired": + return false + default: + return nil + } + } + + private static func extractCookieValue(name: String, from cookieHeader: String) -> String? { + cookieHeader + .split(separator: ";") + .compactMap { part -> (String, String)? in + let pieces = part.split(separator: "=", maxSplits: 1).map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard pieces.count == 2 else { return nil } + return (pieces[0], pieces[1]) + } + .first { $0.0 == name }? + .1 + } + + private static func hasCSRF(in cookieHeader: String) -> Bool { + self.extractCookieValue(name: "login_aliyunid_csrf", from: cookieHeader) != nil || + self.extractCookieValue(name: "csrf", from: cookieHeader) != nil + } + + static func cookieNames(from cookieHeader: String) -> [String] { + CookieHeaderNormalizer.pairs(from: cookieHeader) + .map(\.name) + .filter { !$0.isEmpty } + .uniquedSorted() + } + + static func cookieNamesDescription(from cookieHeader: String) -> String { + let names = self.cookieNames(from: cookieHeader) + return names.isEmpty ? "none" : names.joined(separator: ",") + } + + private static func extractSECToken(from html: String) -> String? { + let patterns = [ + #""secToken"\s*:\s*"([^"]+)""#, + #""sec_token"\s*:\s*"([^"]+)""#, + #"secToken['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + #"sec_token['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + ] + for pattern in patterns { + if let token = self.matchFirstGroup(pattern: pattern, in: html), !token.isEmpty { + return token + } + } + return nil + } + + private static func matchFirstGroup(pattern: String, in text: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return nil + } + let range = NSRange(text.startIndex.. 1, + let valueRange = Range(match.range(at: 1), in: text) + else { + return nil + } + let value = text[valueRange].trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : String(value) + } +} + +extension [String] { + fileprivate func uniquedSorted() -> [String] { + Array(Set(self)).sorted() + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift new file mode 100644 index 000000000..36cee2a2b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift @@ -0,0 +1,96 @@ +import Foundation + +public struct AlibabaTokenPlanUsageSnapshot: Sendable { + public let planName: String? + public let usedQuota: Double? + public let totalQuota: Double? + public let remainingQuota: Double? + public let resetsAt: Date? + public let updatedAt: Date + + public init( + planName: String?, + usedQuota: Double?, + totalQuota: Double?, + remainingQuota: Double?, + resetsAt: Date?, + updatedAt: Date) + { + self.planName = planName + self.usedQuota = usedQuota + self.totalQuota = totalQuota + self.remainingQuota = remainingQuota + self.resetsAt = resetsAt + self.updatedAt = updatedAt + } +} + +extension AlibabaTokenPlanUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let primary: RateWindow? = Self.usedPercent( + used: self.usedQuota, + total: self.totalQuota, + remaining: self.remainingQuota).map { + RateWindow( + usedPercent: $0, + windowMinutes: 30 * 24 * 60, + resetsAt: self.resetsAt, + resetDescription: Self.quotaDetail( + used: self.usedQuota, + total: self.totalQuota, + remaining: self.remainingQuota)) + } + + let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (planName?.isEmpty ?? true) ? nil : planName + let identity = ProviderIdentitySnapshot( + providerID: .alibabatokenplan, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + private static func usedPercent(used: Double?, total: Double?, remaining: Double?) -> Double? { + guard let total, total > 0 else { return nil } + let usedValue: Double? = if let used { + used + } else if let remaining { + total - remaining + } else { + nil + } + guard let usedValue else { return nil } + let normalizedUsed = max(0, min(usedValue, total)) + return normalizedUsed / total * 100 + } + + private static func quotaDetail(used: Double?, total: Double?, remaining: Double?) -> String? { + if let used, let total, total > 0 { + return "\(self.format(used)) / \(self.format(total)) credits used" + } + if let remaining, let total, total > 0 { + return "\(Self.format(remaining)) / \(Self.format(total)) credits left" + } + if let remaining { + return "\(Self.format(remaining)) credits left" + } + return nil + } + + private static func format(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = true + formatter.maximumFractionDigits = value.rounded() == value ? 0 : 2 + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index fd6301498..d34d17adb 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -61,6 +61,7 @@ public enum ProviderDescriptorRegistry { .opencode: OpenCodeProviderDescriptor.descriptor, .opencodego: OpenCodeGoProviderDescriptor.descriptor, .alibaba: AlibabaCodingPlanProviderDescriptor.descriptor, + .alibabatokenplan: AlibabaTokenPlanProviderDescriptor.descriptor, .factory: FactoryProviderDescriptor.descriptor, .gemini: GeminiProviderDescriptor.descriptor, .antigravity: AntigravityProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index c210fce09..8370c3e5e 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -10,6 +10,7 @@ public struct ProviderSettingsSnapshot: Sendable { opencode: OpenCodeProviderSettings? = nil, opencodego: OpenCodeProviderSettings? = nil, alibaba: AlibabaCodingPlanProviderSettings? = nil, + alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, factory: FactoryProviderSettings? = nil, minimax: MiniMaxProviderSettings? = nil, manus: ManusProviderSettings? = nil, @@ -38,6 +39,7 @@ public struct ProviderSettingsSnapshot: Sendable { opencode: opencode, opencodego: opencodego, alibaba: alibaba, + alibabaTokenPlan: alibabaTokenPlan, factory: factory, minimax: minimax, manus: manus, @@ -143,6 +145,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct AlibabaTokenPlanProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource = .auto, manualCookieHeader: String? = nil) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct FactoryProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -356,6 +368,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let opencode: OpenCodeProviderSettings? public let opencodego: OpenCodeProviderSettings? public let alibaba: AlibabaCodingPlanProviderSettings? + public let alibabaTokenPlan: AlibabaTokenPlanProviderSettings? public let factory: FactoryProviderSettings? public let minimax: MiniMaxProviderSettings? public let manus: ManusProviderSettings? @@ -389,6 +402,7 @@ public struct ProviderSettingsSnapshot: Sendable { opencode: OpenCodeProviderSettings?, opencodego: OpenCodeProviderSettings?, alibaba: AlibabaCodingPlanProviderSettings?, + alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, factory: FactoryProviderSettings?, minimax: MiniMaxProviderSettings?, manus: ManusProviderSettings?, @@ -417,6 +431,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.opencode = opencode self.opencodego = opencodego self.alibaba = alibaba + self.alibabaTokenPlan = alibabaTokenPlan self.factory = factory self.minimax = minimax self.manus = manus @@ -446,6 +461,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case opencode(ProviderSettingsSnapshot.OpenCodeProviderSettings) case opencodego(ProviderSettingsSnapshot.OpenCodeProviderSettings) case alibaba(ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings) + case alibabaTokenPlan(ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings) case factory(ProviderSettingsSnapshot.FactoryProviderSettings) case minimax(ProviderSettingsSnapshot.MiniMaxProviderSettings) case manus(ProviderSettingsSnapshot.ManusProviderSettings) @@ -476,6 +492,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var opencode: ProviderSettingsSnapshot.OpenCodeProviderSettings? public var opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings? public var alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? + public var alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings? public var factory: ProviderSettingsSnapshot.FactoryProviderSettings? public var minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? public var manus: ProviderSettingsSnapshot.ManusProviderSettings? @@ -510,6 +527,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .opencode(value): self.opencode = value case let .opencodego(value): self.opencodego = value case let .alibaba(value): self.alibaba = value + case let .alibabaTokenPlan(value): self.alibabaTokenPlan = value case let .factory(value): self.factory = value case let .minimax(value): self.minimax = value case let .manus(value): self.manus = value @@ -542,6 +560,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { opencode: self.opencode, opencodego: self.opencodego, alibaba: self.alibaba, + alibabaTokenPlan: self.alibabaTokenPlan, factory: self.factory, minimax: self.minimax, manus: self.manus, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 3bac0341b..efbd4ff12 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -11,6 +11,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case opencode case opencodego case alibaba + case alibabatokenplan case factory case gemini case antigravity diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 22b7bb046..7c233c545 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -367,7 +367,8 @@ enum CostUsageScanner { filtered.claudeLogProviderFilter = .vertexAIOnly } return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) - case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, + case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, + .alibabatokenplan, .factory, .copilot, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, .groq, .llmproxy, diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 60b8d14fe..c7ae41280 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -57,6 +57,7 @@ enum ProviderChoice: String, AppEnum { case .claude: self = .claude case .gemini: self = .gemini case .alibaba: self = .alibaba + case .alibabatokenplan: return nil // Alibaba Token Plan not yet supported in widgets case .antigravity: self = .antigravity case .cursor: return nil // Cursor not yet supported in widgets case .opencode: self = .opencode diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 2cba949f1..2cf31bed7 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -267,6 +267,7 @@ private struct ProviderSwitchChip: View { case .opencode: "OpenCode" case .opencodego: "OpenCode Go" case .alibaba: "Alibaba" + case .alibabatokenplan: "Token Plan" case .zai: "z.ai" case .factory: "Droid" case .copilot: "Copilot" @@ -630,7 +631,7 @@ enum WidgetColors { Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) case .opencodego: Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) - case .alibaba: + case .alibaba, .alibabatokenplan: Color(red: 1.0, green: 106 / 255, blue: 0) case .zai: Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255) diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift new file mode 100644 index 000000000..b0c98e8c4 --- /dev/null +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -0,0 +1,515 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AlibabaTokenPlanSettingsReaderTests { + @Test + func `cookie reads from environment`() { + let cookie = AlibabaTokenPlanSettingsReader.cookieHeader(environment: [ + AlibabaTokenPlanSettingsReader.cookieHeaderKey: "\"login_aliyunid_ticket=ticket\"", + ]) + #expect(cookie == "login_aliyunid_ticket=ticket") + } + + @Test + func `default quota URL targets token plan API`() { + let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL + #expect(url.host == "bailian-cs.console.aliyun.com") + #expect(url.absoluteString.contains("queryTokenPlanInstanceInfo")) + #expect(url.absoluteString.contains("BroadScopeAspnGateway")) + } +} + +struct AlibabaTokenPlanCookieHeaderTests { + @Test + func `builds URL scoped headers for API and dashboard`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), + self.cookie(name: "sec_token", value: "shared", domain: ".console.aliyun.com"), + self.cookie(name: "sec_token", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "sec_token", value: "api", domain: "bailian-cs.console.aliyun.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("login_current_pk=account")) + #expect(headers.apiCookieHeader.contains("sec_token=api")) + #expect(!headers.apiCookieHeader.contains("sec_token=dashboard")) + #expect(headers.dashboardCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("sec_token=api")) + } + + @Test + func `cached token plan headers preserve URL scoping`() throws { + let headers = AlibabaTokenPlanCookieHeaders( + apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", + dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") + + let cached = try #require(AlibabaTokenPlanCookieHeaders(cachedHeader: headers.cacheCookieHeader)) + + #expect(cached.apiCookieHeader.contains("api_only=api")) + #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(cached.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!cached.dashboardCookieHeader.contains("api_only=api")) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } +} + +struct AlibabaTokenPlanUsageSnapshotTests { + @Test + func `maps used and total quota to primary window`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset = Date(timeIntervalSince1970: 1_700_100_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 250, + totalQuota: 1000, + remainingQuota: nil, + resetsAt: reset, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == reset) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits used") + #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") + } + + @Test + func `does not create primary window from balance only`() { + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: 700, + resetsAt: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") + } +} + +struct AlibabaTokenPlanUsageParsingTests { + @Test + func `parses token plan payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "tokenPlanInstanceInfo": { + "planName": "TOKEN PLAN", + "status": "VALID", + "quotaInfo": { + "usedQuota": 125, + "totalQuota": 1000, + "remainingQuota": 875 + }, + "periodEndTime": 1701000000000 + } + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "TOKEN PLAN") + #expect(snapshot.usedQuota == 125) + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.remainingQuota == 875) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_701_000_000)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + } + + @Test + func `parses remaining and total quota`() throws { + let body = """ + { + "data": { + "tokenPlanInstanceInfo": { + "packageName": "TOKEN PLAN", + "quotaInfo": { + "remainingCredits": 750, + "totalCredits": 1000 + } + } + }, + "statusCode": 200 + } + """ + let payload = ["successResponse": ["body": body]] + let data = try JSONSerialization.data(withJSONObject: payload) + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: data) + + #expect(snapshot.planName == "TOKEN PLAN") + #expect(snapshot.usedQuota == nil) + #expect(snapshot.remainingQuota == 750) + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 25) + } + + @Test + func `plan only payload stays visible without quota window`() throws { + let json = """ + { + "data": { + "tokenPlanInstanceInfo": { + "planName": "TOKEN PLAN", + "status": "VALID" + } + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + #expect(snapshot.planName == "TOKEN PLAN") + #expect(snapshot.totalQuota == nil) + #expect(snapshot.toUsageSnapshot().primary == nil) + } + + @Test + func `login payload maps to login required`() { + let json = """ + { + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "successResponse": false + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `forbidden payload maps to invalid credentials`() { + let json = """ + { + "statusCode": 403, + "message": "Forbidden" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.invalidCredentials) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `cookie only request continues without SEC token`() async throws { + let registered = URLProtocol.registerClass(AlibabaTokenPlanStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(AlibabaTokenPlanStubURLProtocol.self) + } + AlibabaTokenPlanStubURLProtocol.handler = nil + } + + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "alibaba-token-plan.test", request.httpMethod == "GET" { + return Self.makeResponse(url: url, body: "", statusCode: 200) + } + + if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") + #expect(request.value(forHTTPHeaderField: "Referer") == AlibabaTokenPlanUsageFetcher.dashboardURL + .absoluteString) + let body = Self.requestBodyString(from: request) + #expect(!body.contains("sec_token=")) + #expect(body.contains("commodityCode")) + #expect(body.contains("sfm_tokenplanteams_dp_cn")) + #expect(body.contains("onlyLatestOne")) + let json = """ + { + "data": { + "tokenPlanInstanceInfo": { + "planName": "TOKEN PLAN" + } + }, + "status_code": 0 + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", + dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test"], + session: session) + + #expect(snapshot.planName == "TOKEN PLAN") + } + + @Test + func `redirect preserves cookie only for same host HTTPS requests`() throws { + let sourceURL = try #require(URL(string: "https://bailian-cs.console.aliyun.com/data/api.json")) + let sameHostURL = try #require(URL(string: "https://bailian-cs.console.aliyun.com/redirected")) + let crossHostURL = try #require(URL(string: "https://signin.aliyun.com/login")) + let insecureURL = try #require(URL(string: "http://bailian-cs.console.aliyun.com/redirected")) + let response = try #require(HTTPURLResponse( + url: sourceURL, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: nil)) + + var sameHostRequest = URLRequest(url: sameHostURL) + sameHostRequest.setValue("old=value", forHTTPHeaderField: "Cookie") + let sameHostRedirect = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: sameHostRequest, + cookieHeader: "login_aliyunid_ticket=ticket")) + #expect(sameHostRedirect.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket") + + var crossHostRequest = URLRequest(url: crossHostURL) + crossHostRequest.setValue("old=value", forHTTPHeaderField: "Cookie") + let crossHostRedirect = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: crossHostRequest, + cookieHeader: "login_aliyunid_ticket=ticket")) + #expect(crossHostRedirect.value(forHTTPHeaderField: "Cookie") == nil) + + let insecureRedirect = AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: URLRequest(url: insecureURL), + cookieHeader: "login_aliyunid_ticket=ticket") + #expect(insecureRedirect == nil) + } + + private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func requestBodyString(from request: URLRequest) -> String { + if let data = request.httpBody { + return String(data: data, encoding: .utf8) ?? "" + } + if let stream = request.httpBodyStream { + stream.open() + defer { + stream.close() + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + return String(data: data, encoding: .utf8) ?? "" + } + return "" + } +} + +@Suite(.serialized) +struct AlibabaTokenPlanWebStrategyTests { + 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 `auto web strategy surfaces cookie import errors`() async throws { + let strategy = AlibabaTokenPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + CookieHeaderCache.clear(provider: .alibabatokenplan) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie( + details: "macOS Keychain denied access to Chrome Safe Storage.") + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + } + + #expect(await strategy.isAvailable(context)) + + do { + _ = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false) + Issue.record("Expected cookie import failure to be surfaced") + } catch let error as AlibabaTokenPlanSettingsError { + guard case let .missingCookie(details) = error else { + Issue.record("Expected missingCookie, got \(error)") + return + } + #expect(details == "macOS Keychain denied access to Chrome Safe Storage.") + #expect(error.localizedDescription.contains("Alibaba Token Plan")) + #expect(!error.localizedDescription.contains("Alibaba Coding Plan")) + } + } + + @Test + func `auto web strategy imports URL scoped token plan cookies`() throws { + let strategy = AlibabaTokenPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + CookieHeaderCache.clear(provider: .alibabatokenplan) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "api_only", value: "api", domain: "bailian-cs.console.aliyun.com"), + self.cookie(name: "alibabacloud_only", value: "cloud", domain: ".alibabacloud.com"), + ], + sourceLabel: "Chrome Default") + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + CookieHeaderCache.clear(provider: .alibabatokenplan) + } + + let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) + + #expect(headers.apiCookieHeader != headers.dashboardCookieHeader) + #expect(!headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.apiCookieHeader.contains("api_only=api")) + #expect(!headers.apiCookieHeader.contains("alibabacloud_only=cloud")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("api_only=api")) + #expect(!headers.dashboardCookieHeader.contains("alibabacloud_only=cloud")) + + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import") + } + let cachedHeaders = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true) + #expect(cachedHeaders.apiCookieHeader == headers.apiCookieHeader) + #expect(cachedHeaders.dashboardCookieHeader == headers.dashboardCookieHeader) + #expect(strategy.id == "alibaba-token-plan.web") + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } +} + +final class AlibabaTokenPlanStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return host == "bailian.console.aliyun.com" || + host == "bailian-cs.console.aliyun.com" || + host == "alibaba-token-plan.test" + } + + 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() {} +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index b4231d897..122f8e11b 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -458,4 +458,49 @@ struct ProviderSettingsDescriptorTests { #expect(detailLine == store.sourceLabel(for: .alibaba)) } + + @Test + func `alibaba token plan settings expose cookie controls`() throws { + let suite = "ProviderSettingsDescriptorTests-alibaba-token-plan-settings" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let context = ProviderSettingsContext( + provider: .alibabatokenplan, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + settings.alibabaTokenPlanCookieSource = .manual + let implementation = AlibabaTokenPlanProviderImplementation() + let pickers = implementation.settingsPickers(context: context) + let fields = implementation.settingsFields(context: context) + + #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) + #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) + #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) + } } From 51030de5e8dbfbc3347cdbf95242a9aa8284c1d5 Mon Sep 17 00:00:00 2001 From: YanxinXue Date: Fri, 22 May 2026 10:41:48 +0800 Subject: [PATCH 2/9] Clean up Alibaba token plan support --- Sources/CodexBar/UsageStore.swift | 30 +--- .../Alibaba/AlibabaTokenPlanDebugProbe.swift | 133 ------------------ .../CodexBarWidgetProvider.swift | 5 +- .../CodexBarWidgetProviderTests.swift | 24 ++++ 4 files changed, 32 insertions(+), 160 deletions(-) delete mode 100644 Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanDebugProbe.swift diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index ae9d4f7bf..c7bf750e8 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -958,8 +958,6 @@ extension UsageStore { let ampCookieHeader = self.settings.ampCookieHeader let ollamaCookieSource = self.settings.ollamaCookieSource let ollamaCookieHeader = self.settings.ollamaCookieHeader - let alibabaTokenPlanCookieSource = self.settings.alibabaTokenPlanCookieSource - let alibabaTokenPlanCookieHeader = self.settings.alibabaTokenPlanCookieHeader let processEnvironment = self.environmentBase let openAIDebugContext = self.openAIAPIKeyDebugContext(processEnvironment: processEnvironment) let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment) @@ -1047,12 +1045,6 @@ extension UsageStore { let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "ALIBABA_CODING_PLAN_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .alibabatokenplan: - return await Self.debugAlibabaTokenPlanLog( - browserDetection: browserDetection, - cookieSource: alibabaTokenPlanCookieSource, - cookieHeader: alibabaTokenPlanCookieHeader, - environment: processEnvironment) case .augment: return await Self.debugAugmentLog() case .amp: @@ -1081,9 +1073,10 @@ extension UsageStore { configToken: nil, hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) - case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .abacus, .mistral, .codebuff, .crof, - .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, .grok, .groq, .llmproxy, .deepgram: + case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .vertexai, + .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .abacus, .mistral, + .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, .grok, .groq, + .llmproxy, .deepgram: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } @@ -1390,21 +1383,6 @@ extension UsageStore { } } - private static func debugAlibabaTokenPlanLog( - browserDetection: BrowserDetection, - cookieSource: ProviderCookieSource, - cookieHeader: String, - environment: [String: String]) async -> String - { - await runWithTimeout(seconds: 20) { - await AlibabaTokenPlanDebugProbe.debugLog( - cookieSource: cookieSource, - manualCookieHeader: cookieHeader, - environment: environment, - browserDetection: browserDetection) - } - } - private func detectVersions() { let implementations = ProviderCatalog.all let browserDetection = self.browserDetection diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanDebugProbe.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanDebugProbe.swift deleted file mode 100644 index e12498940..000000000 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanDebugProbe.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation - -public enum AlibabaTokenPlanDebugProbe { - public static func debugLog( - cookieSource: ProviderCookieSource, - manualCookieHeader: String, - environment: [String: String], - browserDetection: BrowserDetection) async -> String - { - var lines: [String] = [ - "Alibaba Token Plan debug", - "Cookie source: \(cookieSource.rawValue)", - ] - - do { - let headers = try self.resolveCookieHeaders( - cookieSource: cookieSource, - manualCookieHeader: manualCookieHeader, - environment: environment, - browserDetection: browserDetection, - lines: &lines) - lines.append("API cookie names: \(self.namesDescription(headers.apiCookieNames))") - lines.append("Dashboard cookie names: \(self.namesDescription(headers.dashboardCookieNames))") - lines.append("Has sec_token cookie: \(headers.hasCookie(named: "sec_token") ? "yes" : "no")") - lines.append("Has login ticket: \(headers.hasCookie(named: "login_aliyunid_ticket") ? "yes" : "no")") - lines.append("Has account cookie: \(self.hasAlibabaAccountCookie(headers) ? "yes" : "no")") - - do { - let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( - apiCookieHeader: headers.apiCookieHeader, - dashboardCookieHeader: headers.dashboardCookieHeader, - environment: environment) - lines.append("Fetch: success") - lines.append("Plan present: \(snapshot.planName == nil ? "no" : "yes")") - lines.append("Quota total present: \(snapshot.totalQuota == nil ? "no" : "yes")") - lines.append("Quota used present: \(snapshot.usedQuota == nil ? "no" : "yes")") - lines.append("Quota remaining present: \(snapshot.remainingQuota == nil ? "no" : "yes")") - lines.append("Reset present: \(snapshot.resetsAt == nil ? "no" : "yes")") - } catch { - lines.append("Fetch: failed") - lines.append("Fetch error: \(type(of: error))") - lines.append("Fetch message: \(error.localizedDescription)") - } - } catch { - lines.append("Cookie resolution: failed") - lines.append("Cookie error: \(type(of: error))") - lines.append("Cookie message: \(error.localizedDescription)") - } - - return lines.joined(separator: "\n") - } - - private static func resolveCookieHeaders( - cookieSource: ProviderCookieSource, - manualCookieHeader: String, - environment: [String: String], - browserDetection: BrowserDetection, - lines: inout [String]) throws -> AlibabaTokenPlanCookieHeaders - { - if cookieSource == .manual { - let hasManualCookie = !manualCookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - lines.append("Manual cookie configured: \(hasManualCookie ? "yes" : "no")") - guard let headers = AlibabaTokenPlanCookieHeaders(singleHeader: manualCookieHeader) else { - throw AlibabaTokenPlanSettingsError.invalidCookie - } - lines.append("Cookie source selected: manual") - return headers - } - - if let envCookie = AlibabaTokenPlanSettingsReader.cookieHeader(environment: environment), - let headers = AlibabaTokenPlanCookieHeaders(singleHeader: envCookie) - { - lines.append("Environment cookie configured: yes") - lines.append("Cookie source selected: environment") - return headers - } - lines.append("Environment cookie configured: no") - - #if os(macOS) - if let cached = CookieHeaderCache.load(provider: .alibabatokenplan) { - lines.append("Cached browser cookie: yes") - lines.append("Cached browser source: \(cached.sourceLabel)") - if let headers = AlibabaTokenPlanCookieHeaders(cachedHeader: cached.cookieHeader) { - lines.append("Cookie source selected: browser-cache") - return headers - } - lines.append("Cached browser cookie parseable: no") - } else { - lines.append("Cached browser cookie: no") - } - - var importLines: [String] = [] - let session = try AlibabaCodingPlanCookieImporter.importSession( - browserDetection: browserDetection, - logger: { importLines.append($0) }) - lines.append("Browser import source: \(session.sourceLabel)") - if importLines.isEmpty { - lines.append("Browser import log: empty") - } else { - lines.append("Browser import log:") - lines.append(contentsOf: importLines.map { " \($0)" }) - } - - let rawNames = session.cookies.map(\.name).filter { !$0.isEmpty }.uniquedSorted() - lines.append("Raw imported cookie names: \(self.namesDescription(rawNames))") - - guard let headers = AlibabaTokenPlanCookieHeader.headers(from: session.cookies) else { - throw AlibabaTokenPlanSettingsError.missingCookie( - details: "No Alibaba Token Plan browser cookies were available after import.") - } - lines.append("Cookie source selected: browser-import") - return headers - #else - throw AlibabaTokenPlanSettingsError.missingCookie(details: "Browser cookie import is only available on macOS.") - #endif - } - - private static func hasAlibabaAccountCookie(_ headers: AlibabaTokenPlanCookieHeaders) -> Bool { - headers.hasCookie(named: "login_aliyunid_pk") || - headers.hasCookie(named: "login_current_pk") || - headers.hasCookie(named: "login_aliyunid") - } - - private static func namesDescription(_ names: [String]) -> String { - names.isEmpty ? "none" : names.joined(separator: ",") - } -} - -extension [String] { - fileprivate func uniquedSorted() -> [String] { - Array(Set(self)).sorted() - } -} diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index c7ae41280..fe32882c3 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -8,6 +8,7 @@ enum ProviderChoice: String, AppEnum { case claude case gemini case alibaba + case alibabatokenplan case antigravity case zai case copilot @@ -23,6 +24,7 @@ enum ProviderChoice: String, AppEnum { .claude: DisplayRepresentation(title: "Claude"), .gemini: DisplayRepresentation(title: "Gemini"), .alibaba: DisplayRepresentation(title: "Alibaba"), + .alibabatokenplan: DisplayRepresentation(title: "Alibaba Token Plan"), .antigravity: DisplayRepresentation(title: "Antigravity"), .zai: DisplayRepresentation(title: "z.ai"), .copilot: DisplayRepresentation(title: "Copilot"), @@ -38,6 +40,7 @@ enum ProviderChoice: String, AppEnum { case .claude: .claude case .gemini: .gemini case .alibaba: .alibaba + case .alibabatokenplan: .alibabatokenplan case .antigravity: .antigravity case .zai: .zai case .copilot: .copilot @@ -57,7 +60,7 @@ enum ProviderChoice: String, AppEnum { case .claude: self = .claude case .gemini: self = .gemini case .alibaba: self = .alibaba - case .alibabatokenplan: return nil // Alibaba Token Plan not yet supported in widgets + case .alibabatokenplan: self = .alibabatokenplan case .antigravity: self = .antigravity case .cursor: return nil // Cursor not yet supported in widgets case .opencode: self = .opencode diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index e951d59db..a496dfc0d 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -10,6 +10,12 @@ struct CodexBarWidgetProviderTests { #expect(ProviderChoice.alibaba.provider == .alibaba) } + @Test + func `provider choice supports alibaba token plan`() { + #expect(ProviderChoice(provider: .alibabatokenplan) == .alibabatokenplan) + #expect(ProviderChoice.alibabatokenplan.provider == .alibabatokenplan) + } + @Test func `provider choice supports opencode go`() { #expect(ProviderChoice(provider: .opencodego) == .opencodego) @@ -41,6 +47,24 @@ struct CodexBarWidgetProviderTests { #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.alibaba]) } + @Test + func `supported providers keep alibaba token plan when it is the only enabled provider`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let entry = WidgetSnapshot.ProviderEntry( + provider: .alibabatokenplan, + updatedAt: now, + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + let snapshot = WidgetSnapshot(entries: [entry], enabledProviders: [.alibabatokenplan], generatedAt: now) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.alibabatokenplan]) + } + @Test func `codex weekly only widget rows omit session`() { let now = Date(timeIntervalSince1970: 1_700_000_000) From 54b8d8324e4a85b9ba23f355dcd1db86a77d4cd0 Mon Sep 17 00:00:00 2001 From: YanxinXue Date: Fri, 22 May 2026 11:48:24 +0800 Subject: [PATCH 3/9] Fix Alibaba token plan review feedback --- Sources/CodexBar/UsageStore.swift | 10 +- .../AlibabaTokenPlanCookieHeader.swift | 13 +- .../AlibabaTokenPlanProviderDescriptor.swift | 5 +- .../AlibabaTokenPlanUsageFetcher.swift | 20 +-- .../AlibabaTokenPlanProviderTests.swift | 125 +++++++++++++++++- 5 files changed, 154 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 0399928e1..608d779d9 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -932,7 +932,7 @@ extension UsageStore { await AugmentStatusProbe.latestDumps() } - // swiftlint:disable:next cyclomatic_complexity function_body_length + // swiftlint:disable:next function_body_length func debugLog(for provider: UsageProvider) async -> String { if let cached = self.probeLogs[provider], !cached.isEmpty { return cached @@ -1073,10 +1073,10 @@ extension UsageStore { configToken: nil, hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) - case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .vertexai, - .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .abacus, .mistral, - .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, .grok, .groq, - .t3chat, .llmproxy, .deepgram: + case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, + .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, + .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, + .grok, .groq, .t3chat, .llmproxy, .deepgram: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift index 1c12ab63c..03791cc95 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift @@ -75,9 +75,16 @@ struct AlibabaTokenPlanCookieHeaders: Sendable { } enum AlibabaTokenPlanCookieHeader { - static func headers(from cookies: [HTTPCookie]) -> AlibabaTokenPlanCookieHeaders? { - guard let apiHeader = self.header(from: cookies, targetURL: AlibabaTokenPlanUsageFetcher.defaultQuotaURL), - let dashboardHeader = self.header(from: cookies, targetURL: AlibabaTokenPlanUsageFetcher.dashboardURL) + static func headers( + from cookies: [HTTPCookie], + environment: [String: String] = ProcessInfo.processInfo.environment) -> AlibabaTokenPlanCookieHeaders? + { + guard let apiHeader = self.header( + from: cookies, + targetURL: AlibabaTokenPlanUsageFetcher.resolveQuotaURL(environment: environment)), + let dashboardHeader = self.header( + from: cookies, + targetURL: AlibabaTokenPlanUsageFetcher.dashboardURL(environment: environment)) else { return nil } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift index 8e5e5aa63..83f04da4c 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -191,7 +191,10 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { browserDetection: context.browserDetection, logger: { importLog.append($0) }) let rawCookieNames = session.cookies.map(\.name).filter { !$0.isEmpty }.uniquedSorted() - guard let headers = AlibabaTokenPlanCookieHeader.headers(from: session.cookies) else { + guard let headers = AlibabaTokenPlanCookieHeader.headers( + from: session.cookies, + environment: context.env) + else { Self.log.warning( "Alibaba Token Plan browser cookie header was empty", metadata: [ diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 23b55c510..ba22e7936 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -73,10 +73,16 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } let url = self.resolveQuotaURL(environment: environment) + let redirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedAPIHeader) + let session = overrideSession ?? URLSession( + configuration: .default, + delegate: redirectDiagnostics, + delegateQueue: nil) let secToken = await self.resolveSECToken( dashboardCookieHeader: normalizedDashboardHeader, apiCookieHeader: normalizedAPIHeader, - environment: environment) + environment: environment, + session: session) let anonymousID = self.extractCookieValue(name: "cna", from: normalizedAPIHeader) Self.log.info( "Fetching Alibaba Token Plan usage", @@ -109,11 +115,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let data: Data let response: URLResponse - let redirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedAPIHeader) - let session = overrideSession ?? URLSession( - configuration: .default, - delegate: redirectDiagnostics, - delegateQueue: nil) do { (data, response) = try await session.data(for: request) } catch { @@ -266,7 +267,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private static func resolveSECToken( dashboardCookieHeader: String, apiCookieHeader: String, - environment: [String: String]) async -> String? + environment: [String: String], + session: URLSession) async -> String? { let cookieSECToken = self.extractCookieValue(name: "sec_token", from: dashboardCookieHeader) ?? self.extractCookieValue(name: "sec_token", from: apiCookieHeader) @@ -279,7 +281,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", forHTTPHeaderField: "Accept") - if let (data, response) = try? await URLSession.shared.data(for: request), + if let (data, response) = try? await session.data(for: request), let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let html = String(data: data, encoding: .utf8), @@ -320,7 +322,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return components?.url } - private static func dashboardURL(environment: [String: String]) -> URL { + static func dashboardURL(environment: [String: String]) -> URL { if let host = AlibabaTokenPlanSettingsReader.hostOverride(environment: environment), let base = URL(string: host)?.scheme == nil ? URL(string: "https://\(host)") : URL(string: host), var components = URLComponents(url: base, resolvingAgainstBaseURL: false), diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index b0c98e8c4..af79bda97 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -55,6 +55,30 @@ struct AlibabaTokenPlanCookieHeaderTests { #expect(!cached.dashboardCookieHeader.contains("api_only=api")) } + @Test + func `builds headers from environment scoped URLs`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), + self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian-cs.console.aliyun.com"), + self.cookie(name: "prod_dashboard_only", value: "prod-dashboard", domain: "bailian.console.aliyun.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json", + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ])) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("api_only=api")) + #expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) + } + private func cookie( name: String, value: String, @@ -111,6 +135,7 @@ struct AlibabaTokenPlanUsageSnapshotTests { } } +@Suite(.serialized) struct AlibabaTokenPlanUsageParsingTests { @Test func `parses token plan payload`() throws { @@ -276,6 +301,52 @@ struct AlibabaTokenPlanUsageParsingTests { #expect(snapshot.planName == "TOKEN PLAN") } + @Test + func `SEC token preflight uses injected session`() async throws { + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "session-token.test", request.httpMethod == "GET" { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + if url.host == "session-token.test", request.httpMethod == "POST" { + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=session-html-token")) + let json = """ + { + "data": { + "tokenPlanInstanceInfo": { + "planName": "TOKEN PLAN" + } + }, + "status_code": 0 + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://session-token.test"], + session: session) + + #expect(snapshot.planName == "TOKEN PLAN") + } + @Test func `redirect preserves cookie only for same host HTTPS requests`() throws { let sourceURL = try #require(URL(string: "https://bailian-cs.console.aliyun.com/data/api.json")) @@ -463,6 +534,57 @@ struct AlibabaTokenPlanWebStrategyTests { #expect(strategy.id == "alibaba-token-plan.web") } + @Test + func `auto web strategy scopes imported cookies to environment overrides`() throws { + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let environment = [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json", + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ] + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: settings, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + CookieHeaderCache.clear(provider: .alibabatokenplan) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), + self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian-cs.console.aliyun.com"), + self.cookie( + name: "prod_dashboard_only", + value: "prod-dashboard", + domain: "bailian.console.aliyun.com"), + ], + sourceLabel: "Chrome Default") + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + CookieHeaderCache.clear(provider: .alibabatokenplan) + } + + let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) + + #expect(headers.apiCookieHeader.contains("api_only=api")) + #expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) + } + private func cookie( name: String, value: String, @@ -488,7 +610,8 @@ final class AlibabaTokenPlanStubURLProtocol: URLProtocol { guard let host = request.url?.host else { return false } return host == "bailian.console.aliyun.com" || host == "bailian-cs.console.aliyun.com" || - host == "alibaba-token-plan.test" + host == "alibaba-token-plan.test" || + host == "session-token.test" } override static func canonicalRequest(for request: URLRequest) -> URLRequest { From 6ec8c9187b6dda5e0ff855ec30952f3ae0507eb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 08:01:17 +0100 Subject: [PATCH 4/9] test: scope Alibaba token plan URL protocol stub --- Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index af79bda97..d5d379f76 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -248,11 +248,7 @@ struct AlibabaTokenPlanUsageParsingTests { @Test func `cookie only request continues without SEC token`() async throws { - let registered = URLProtocol.registerClass(AlibabaTokenPlanStubURLProtocol.self) defer { - if registered { - URLProtocol.unregisterClass(AlibabaTokenPlanStubURLProtocol.self) - } AlibabaTokenPlanStubURLProtocol.handler = nil } From fe25a2faf93771d4488179c80c8ee4107f811a17 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 08:07:45 +0100 Subject: [PATCH 5/9] fix: classify Alibaba token plan login pages --- .../AlibabaTokenPlanUsageFetcher.swift | 16 ++++++++++++++- .../AlibabaTokenPlanProviderTests.swift | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index ba22e7936..6f65b18f3 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -185,7 +185,15 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { throw AlibabaTokenPlanUsageError.parseFailed("Empty response body") } - let object = try JSONSerialization.jsonObject(with: data, options: []) + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data, options: []) + } catch { + if self.isLikelyLoginHTML(data) { + throw AlibabaTokenPlanUsageError.loginRequired + } + throw AlibabaTokenPlanUsageError.parseFailed("Invalid JSON response") + } let expanded = self.expandedJSON(object) guard let dictionary = expanded as? [String: Any] else { throw AlibabaTokenPlanUsageError.parseFailed("Unexpected payload") @@ -536,6 +544,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "instanceKeys=\(instanceKeys.joined(separator: ","))" } + private static func isLikelyLoginHTML(_ data: Data) -> Bool { + guard let text = String(data: data, encoding: .utf8)?.lowercased() else { return false } + return text.contains(" Int { if let status = self.anyString(for: ["status", "instanceStatus", "state"], in: source)?.uppercased() { if ["VALID", "ACTIVE", "NORMAL"].contains(status) { diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index d5d379f76..0cce6471d 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -246,6 +246,26 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `html login payload maps to login required`() { + let html = """ + + Please login to Alibaba Cloud + + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(html.utf8)) + } + } + + @Test + func `non json payload maps to parse failed`() { + #expect(throws: AlibabaTokenPlanUsageError.parseFailed("Invalid JSON response")) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data("not-json".utf8)) + } + } + @Test func `cookie only request continues without SEC token`() async throws { defer { From 35f300682e5ffd8046764b9bddbbf4b71d2167bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 08:17:34 +0100 Subject: [PATCH 6/9] fix: keep Alibaba token plan redirect cookies scoped --- .../AlibabaTokenPlanUsageFetcher.swift | 50 +++++++++++++++---- .../AlibabaTokenPlanProviderTests.swift | 20 ++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 6f65b18f3..cf8cd64b3 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -26,6 +26,7 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { } } +// swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { private static let log = CodexBarLog.logger("alibaba-token-plan") private static let gatewayBaseURLString = "https://bailian-cs.console.aliyun.com" @@ -73,16 +74,37 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } let url = self.resolveQuotaURL(environment: environment) - let redirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedAPIHeader) - let session = overrideSession ?? URLSession( - configuration: .default, - delegate: redirectDiagnostics, - delegateQueue: nil) + let apiRedirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedAPIHeader) + let dashboardRedirectDiagnostics: RedirectDiagnostics? + let apiSession: URLSession + let dashboardSession: URLSession + if let overrideSession { + apiSession = overrideSession + dashboardSession = overrideSession + dashboardRedirectDiagnostics = nil + } else { + let dashboardDiagnostics = RedirectDiagnostics(cookieHeader: normalizedDashboardHeader) + apiSession = URLSession( + configuration: .default, + delegate: apiRedirectDiagnostics, + delegateQueue: nil) + dashboardSession = URLSession( + configuration: .default, + delegate: dashboardDiagnostics, + delegateQueue: nil) + dashboardRedirectDiagnostics = dashboardDiagnostics + } + defer { + if overrideSession == nil { + apiSession.invalidateAndCancel() + dashboardSession.invalidateAndCancel() + } + } let secToken = await self.resolveSECToken( dashboardCookieHeader: normalizedDashboardHeader, apiCookieHeader: normalizedAPIHeader, environment: environment, - session: session) + session: dashboardSession) let anonymousID = self.extractCookieValue(name: "cna", from: normalizedAPIHeader) Self.log.info( "Fetching Alibaba Token Plan usage", @@ -116,7 +138,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let data: Data let response: URLResponse do { - (data, response) = try await session.data(for: request) + (data, response) = try await apiSession.data(for: request) } catch { Self.log.error( "Alibaba Token Plan request failed", @@ -126,12 +148,20 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { ]) throw AlibabaTokenPlanUsageError.networkError(error.localizedDescription) } - if !redirectDiagnostics.redirects.isEmpty { + if let dashboardRedirectDiagnostics, !dashboardRedirectDiagnostics.redirects.isEmpty { + Self.log.info( + "Alibaba Token Plan dashboard redirects", + metadata: [ + "count": "\(dashboardRedirectDiagnostics.redirects.count)", + "items": dashboardRedirectDiagnostics.redirects.joined(separator: " | "), + ]) + } + if !apiRedirectDiagnostics.redirects.isEmpty { Self.log.info( "Alibaba Token Plan redirects", metadata: [ - "count": "\(redirectDiagnostics.redirects.count)", - "items": redirectDiagnostics.redirects.joined(separator: " | "), + "count": "\(apiRedirectDiagnostics.redirects.count)", + "items": apiRedirectDiagnostics.redirects.joined(separator: " | "), ]) } guard let httpResponse = response as? HTTPURLResponse else { diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 0cce6471d..853f2412c 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -398,6 +398,26 @@ struct AlibabaTokenPlanUsageParsingTests { #expect(insecureRedirect == nil) } + @Test + func `dashboard redirect preserves dashboard cookie header`() throws { + let sourceURL = try #require(URL(string: "https://bailian.console.aliyun.com/cn-beijing")) + let targetURL = try #require(URL(string: "https://bailian.console.aliyun.com/redirected")) + let response = try #require(HTTPURLResponse( + url: sourceURL, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: nil)) + var request = URLRequest(url: targetURL) + request.setValue("api_only=wrong", forHTTPHeaderField: "Cookie") + + let redirected = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: request, + cookieHeader: "dashboard_only=keep")) + + #expect(redirected.value(forHTTPHeaderField: "Cookie") == "dashboard_only=keep") + } + private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { let response = HTTPURLResponse( url: url, From 403ca16dad8b16509c741c75417cc209873bacc1 Mon Sep 17 00:00:00 2001 From: YanxinXue Date: Fri, 22 May 2026 18:27:36 +0800 Subject: [PATCH 7/9] Reject non-HTTPS token plan overrides --- .../AlibabaTokenPlanSettingsReader.swift | 10 +++-- .../AlibabaTokenPlanProviderTests.swift | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift index ba7c5c3a3..61948299b 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift @@ -14,15 +14,19 @@ public struct AlibabaTokenPlanSettingsReader: Sendable { public static func hostOverride( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { - self.cleaned(environment[self.hostKey]) + guard let raw = self.cleaned(environment[self.hostKey]) else { return nil } + if let scheme = URL(string: raw)?.scheme { + return scheme.lowercased() == "https" ? raw : nil + } + return raw } public static func quotaURL( environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { guard let raw = self.cleaned(environment[self.quotaURLKey]) else { return nil } - if let url = URL(string: raw), url.scheme != nil { - return url + if let url = URL(string: raw), let scheme = url.scheme { + return scheme.lowercased() == "https" ? url : nil } return URL(string: "https://\(raw)") } diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 853f2412c..65922a235 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -11,6 +11,46 @@ struct AlibabaTokenPlanSettingsReaderTests { #expect(cookie == "login_aliyunid_ticket=ticket") } + @Test + func `quota URL infers HTTPS scheme`() { + let url = AlibabaTokenPlanSettingsReader.quotaURL(environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "quota.token-plan.test/data/api.json", + ]) + + #expect(url?.scheme == "https") + #expect(url?.host == "quota.token-plan.test") + } + + @Test + func `quota URL rejects non HTTPS schemes`() { + let httpURL = AlibabaTokenPlanSettingsReader.quotaURL(environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "http://quota.token-plan.test/data/api.json", + ]) + let ftpURL = AlibabaTokenPlanSettingsReader.quotaURL(environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "ftp://quota.token-plan.test/data/api.json", + ]) + + #expect(httpURL == nil) + #expect(ftpURL == nil) + } + + @Test + func `host override rejects non HTTPS schemes`() { + let httpHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [ + AlibabaTokenPlanSettingsReader.hostKey: "http://dashboard.token-plan.test", + ]) + let httpsHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [ + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ]) + let bareHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [ + AlibabaTokenPlanSettingsReader.hostKey: "dashboard.token-plan.test", + ]) + + #expect(httpHost == nil) + #expect(httpsHost == "https://dashboard.token-plan.test") + #expect(bareHost == "dashboard.token-plan.test") + } + @Test func `default quota URL targets token plan API`() { let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL From 0ccad55300f5e88b9bcf2c980e5d151899230586 Mon Sep 17 00:00:00 2001 From: YanxinXue Date: Fri, 22 May 2026 18:52:32 +0800 Subject: [PATCH 8/9] Fix token plan Linux build --- .../Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift index 03791cc95..60c2ab24b 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif struct AlibabaTokenPlanCookieHeaders: Sendable { private static let cachedAPIHeaderName = "__codexbar_alibaba_token_plan_api" From 10d80156d57941f3c6929ca096d1b8067af77281 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 12:25:22 +0100 Subject: [PATCH 9/9] docs: update changelog for Alibaba token plan hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3015a115..ed16210a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Localization: improve Simplified Chinese settings and menu translations (#1059). Thanks @narallee! +- Alibaba Token Plan: reject non-HTTPS endpoint overrides and keep the provider building on Linux (#1104). Thanks @YanxinXue! - Build scripts: derive the local development signing team ID from the certificate OU before falling back to the CN suffix (#1095). - Menu bar: keep retrying display-change recovery when macOS leaves status items detached from the current screen (#1077, #1088). - Codex: preserve last successful per-account quota snapshots when later network or DNS refreshes fail (#1097, #1101). Thanks @Yuxin-Qiao!