diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 3925ec8b67..59d639b4fd 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -288,11 +288,15 @@ extension UsageMenuCardView.Model { Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? input.metadata.sessionLabel } else if input.provider == .amp { AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) ?? input.metadata.sessionLabel + } else if input.provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel } else { input.metadata.sessionLabel } let secondaryLabel = if input.provider == .amp { AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? input.metadata.weeklyLabel + } else if input.provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? input.metadata.weeklyLabel } else { input.metadata.weeklyLabel } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 0a2284e5ee..a38b7e8875 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -678,11 +678,15 @@ struct MenuDescriptor { Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? metadata.sessionLabel } else if provider == .amp { AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) ?? metadata.sessionLabel + } else if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel } else { metadata.sessionLabel } let secondaryLabel = if provider == .amp { AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? metadata.weeklyLabel + } else if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? metadata.weeklyLabel } else { metadata.weeklyLabel } diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift index d3f0e5c030..0d1b7cb705 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift @@ -37,12 +37,15 @@ struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { - let host = context.settings.alibabaTokenPlanAPIRegion.dashboardURL.host ?? "the selected console" + let region = context.settings.alibabaTokenPlanAPIRegion + let host = region.usesPersonalTokenPlanAPI + ? URL(string: region.quotaBaseURLString)?.host + : region.dashboardURL.host return ProviderCookieSourceUI.subtitle( source: context.settings.alibabaTokenPlanCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies from Model Studio/Bailian.", - manual: "Paste a Cookie header from \(host).", + manual: "Paste a Cookie header from \(host ?? "the selected console").", off: "Alibaba Token Plan cookies are disabled.") } diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 6cb3496cb7..2b5cdfe981 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -195,10 +195,18 @@ extension UsageStore { { return dyn } + if provider == .alibabatokenplan, + let dyn = AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) + { + return dyn + } return metadata?.sessionLabel ?? "Session" }() let secondaryTitle = if provider == .amp { AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? metadata?.weeklyLabel ?? "Weekly" + } else if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? + metadata?.weeklyLabel ?? "Weekly" } else { metadata?.weeklyLabel ?? "Weekly" } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift index ae7453f901..9d31f3bdb9 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift @@ -3,25 +3,44 @@ import Foundation public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { case international = "intl" case chinaMainland = "cn" + case internationalPersonal = "intl-personal" + case chinaMainlandPersonal = "cn-personal" public var displayName: String { switch self { case .international: - "International (modelstudio.console.alibabacloud.com)" + "International — Team (modelstudio.console.alibabacloud.com)" case .chinaMainland: - "China mainland (bailian.console.aliyun.com)" + "China mainland — Team (bailian.console.aliyun.com)" + case .internationalPersonal: + "International — Personal/Solo (modelstudio.console.alibabacloud.com)" + case .chinaMainlandPersonal: + "China mainland — Personal/Solo (bailian.console.aliyun.com)" } } public var gatewayBaseURLString: String { switch self { - case .international: + case .international, .internationalPersonal: "https://modelstudio.console.alibabacloud.com" - case .chinaMainland: + case .chinaMainland, .chinaMainlandPersonal: "https://bailian.console.aliyun.com" } } + public var quotaBaseURLString: String { + switch self { + case .international: + self.gatewayBaseURLString + case .chinaMainland: + self.gatewayBaseURLString + case .internationalPersonal: + "https://bailian-singapore-cs.alibabacloud.com" + case .chinaMainlandPersonal: + "https://bailian-cs.console.aliyun.com" + } + } + public var dashboardOriginURLString: String { self.gatewayBaseURLString } @@ -33,14 +52,22 @@ public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { string: "https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=plan#/efm/subscription/token-plan")! case .chinaMainland: URL(string: "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan")! + case .internationalPersonal: + URL( + string: "https://modelstudio.console.alibabacloud.com/ap-southeast-1/" + + "?tab=plan#/efm/subscription/token-plan/personal")! + case .chinaMainlandPersonal: + URL( + string: "https://bailian.console.aliyun.com/cn-beijing" + + "?tab=plan#/efm/subscription/token-plan/personal")! } } public var currentRegionID: String { switch self { - case .international: + case .international, .internationalPersonal: "ap-southeast-1" - case .chinaMainland: + case .chinaMainland, .chinaMainlandPersonal: "cn-beijing" } } @@ -51,6 +78,38 @@ public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { "sfm_tokenplanteams_dp_intl" case .chinaMainland: "sfm_tokenplanteams_dp_cn" + case .internationalPersonal: + "sfm_tokenplansolo_public_intl" + case .chinaMainlandPersonal: + "sfm_tokenplansolo_public_cn" + } + } + + public var usesPersonalTokenPlanAPI: Bool { + switch self { + case .international, .chinaMainland: + false + case .internationalPersonal, .chinaMainlandPersonal: + true + } + } + + public var personalAPIAction: String { + switch self { + case .international, .internationalPersonal: + "IntlBroadScopeAspnGateway" + case .chinaMainland, .chinaMainlandPersonal: + "BroadScopeAspnGateway" + } + } + + public var personalConsoleSite: String { + switch self { + case .international, .internationalPersonal: + // This is Alibaba's live console contract, including its historical spelling. + "MODELSTUDIO_ALBABACLOUD" + case .chinaMainland, .chinaMainlandPersonal: + "BAILIAN_ALIYUN" } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift new file mode 100644 index 0000000000..6d8d2355a9 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift @@ -0,0 +1,115 @@ +import Foundation + +/// Shared parser for Alibaba Token Plan Personal/Solo rolling-window responses. +/// Both mainland and international variants expose the same payload contract. +enum AlibabaTokenPlanPersonalUsageParser { + static func parse( + from usageData: Data, + subscriptionData: Data?, + quotaConfigData: Data?, + now: Date) throws -> AlibabaTokenPlanUsageSnapshot + { + guard !usageData.isEmpty else { + throw AlibabaTokenPlanUsageError.parseFailed("Empty response body") + } + + let raw: Any + do { + raw = try JSONSerialization.jsonObject(with: usageData) + } catch { + if let text = String(data: usageData, encoding: .utf8)?.lowercased(), + text.contains(" String? { + guard let raw = try? JSONSerialization.jsonObject(with: data) else { return nil } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let plan = OneConsoleJSON.findObject( + containingAnyOf: ["specCode", "spec_code", "planName", "plan_name"], + in: expanded) + else { + return nil + } + for key in ["specCode", "spec_code", "planName", "plan_name"] { + if let value = OneConsoleJSON.string(plan[key])?.lowercased(), !value.isEmpty { + return value + } + } + return nil + } + + private static func displayPlanName(_ planCode: String) -> String { + switch planCode { + case "lite": "Lite" + case "standard": "Standard" + case "pro": "Pro" + case "max": "Max" + default: planCode + } + } + + private static func quotaTotals( + from data: Data, + planCode: String?) -> (fiveHour: Double?, weekly: Double?)? + { + guard let planCode, + let raw = try? JSONSerialization.jsonObject(with: data) + else { + return nil + } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let value = OneConsoleJSON.findFirstValue(forKeys: [planCode], in: expanded), + let quota = value as? [String: Any] + else { + return nil + } + let fiveHour = OneConsoleJSON.number(quota["five_hour"] ?? quota["fiveHour"]) + let weekly = OneConsoleJSON.number(quota["weekly"]) + guard fiveHour != nil || weekly != nil else { return nil } + return (fiveHour, weekly) + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift index a64430d4a9..3340e2e42a 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -7,6 +7,14 @@ import SweetCookieKit public enum AlibabaTokenPlanProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + public static func primaryLabel(window: RateWindow?) -> String? { + window?.windowMinutes == 5 * 60 ? "5-hour" : nil + } + + public static func secondaryLabel(window: RateWindow?) -> String? { + window?.windowMinutes == 7 * 24 * 60 ? "7-day" : nil + } + static func makeDescriptor() -> ProviderDescriptor { #if os(macOS) let browserOrder: BrowserCookieImportOrder = [ diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index d16676b83c..b3a4700e49 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -28,9 +28,20 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { // swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { + private struct PersonalAPIContext: Sendable { + let apiCookieHeader: String + let region: AlibabaTokenPlanAPIRegion + let environment: [String: String] + let session: URLSession + } + private static let log = CodexBarLog.logger("alibaba-token-plan") private static let bssServiceCode = "BssOpenAPI-V3" private static let subscriptionSummaryAction = "GetSubscriptionSummary" + private static let personalConsoleProduct = "sfm_bailian" + private static let personalUsageAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + private static let personalSubscriptionAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription" + private static let personalQuotaConfigAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config" 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" @@ -117,6 +128,16 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { dashboardSession.invalidateAndCancel() } } + + if region.usesPersonalTokenPlanAPI { + return try await self.fetchPersonalUsage( + apiCookieHeader: normalizedAPIHeader, + region: region, + environment: environment, + now: now, + session: apiSession) + } + let secToken = await self.resolveSECToken( dashboardCookieHeader: normalizedDashboardHeader, apiCookieHeader: normalizedAPIHeader, @@ -213,7 +234,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return override } if let host = AlibabaTokenPlanSettingsReader.hostOverride(environment: environment), - let hostURL = self.quotaURL(from: host) + let hostURL = self.quotaURL(from: host, region: region) { return hostURL } @@ -225,13 +246,22 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } static func defaultQuotaURL(region: AlibabaTokenPlanAPIRegion) -> URL { - var components = URLComponents(string: region.gatewayBaseURLString)! + var components = URLComponents(string: region.quotaBaseURLString)! components.path = "/data/api.json" - components.queryItems = [ - URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), - URLQueryItem(name: "product", value: Self.bssServiceCode), - URLQueryItem(name: "_tag", value: ""), - ] + components.queryItems = if region.usesPersonalTokenPlanAPI { + [ + URLQueryItem(name: "action", value: region.personalAPIAction), + URLQueryItem(name: "product", value: Self.personalConsoleProduct), + URLQueryItem(name: "api", value: Self.personalUsageAPI), + URLQueryItem(name: "_v", value: "undefined"), + ] + } else { + [ + URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), + URLQueryItem(name: "product", value: Self.bssServiceCode), + URLQueryItem(name: "_tag", value: ""), + ] + } return components.url! } @@ -280,6 +310,186 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { updatedAt: now) } + private static func fetchPersonalUsage( + apiCookieHeader: String, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String], + now: Date, + session: URLSession) async throws -> AlibabaTokenPlanUsageSnapshot + { + let context = PersonalAPIContext( + apiCookieHeader: apiCookieHeader, + region: region, + environment: environment, + session: session) + self.log.info( + "Fetching Alibaba Token Plan Personal usage", + metadata: [ + "apiHost": self.resolveQuotaURL(region: region, environment: environment).host ?? "unknown", + "region": region.rawValue, + "apiCookieNames": self.cookieNamesDescription(from: apiCookieHeader), + "hasCSRF": self.hasCSRF(in: apiCookieHeader) ? "1" : "0", + "secTokenSource": "not-required", + ]) + + let usageData = try await self.fetchPersonalAPI( + api: self.personalUsageAPI, + dataParameters: [:], + context: context) + let subscriptionData = await self.fetchOptionalPersonalAPI( + api: self.personalSubscriptionAPI, + dataParameters: ["commodityCode": region.tokenPlanProductCode], + context: context) + let quotaConfigData = await self.fetchOptionalPersonalAPI( + api: self.personalQuotaConfigAPI, + dataParameters: [:], + context: context) + + return try AlibabaTokenPlanPersonalUsageParser.parse( + from: usageData, + subscriptionData: subscriptionData, + quotaConfigData: quotaConfigData, + now: now) + } + + private static func fetchOptionalPersonalAPI( + api: String, + dataParameters: [String: String], + context: PersonalAPIContext) async -> Data? + { + do { + return try await self.fetchPersonalAPI( + api: api, + dataParameters: dataParameters, + context: context) + } catch { + self.log.warning( + "Optional Alibaba Token Plan Personal metadata fetch failed", + metadata: ["api": api, "error": error.localizedDescription]) + return nil + } + } + + private static func fetchPersonalAPI( + api: String, + dataParameters: [String: String], + context: PersonalAPIContext) async throws -> Data + { + let url = self.personalAPIURL(api: api, region: context.region, environment: context.environment) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.httpBody = try self.personalAPIRequestBody( + api: api, + dataParameters: dataParameters, + apiCookieHeader: context.apiCookieHeader, + region: context.region, + environment: context.environment) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(context.apiCookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue(context.region.dashboardOriginURLString, forHTTPHeaderField: "Origin") + request.setValue( + self.dashboardURL(region: context.region, environment: context.environment).absoluteString, + forHTTPHeaderField: "Referer") + if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: context.apiCookieHeader) ?? + self.extractCookieValue(name: "csrf", from: context.apiCookieHeader) + { + request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") + request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await context.session.data(for: request) + } catch { + throw AlibabaTokenPlanUsageError.networkError(error.localizedDescription) + } + guard let httpResponse = response as? HTTPURLResponse else { + throw AlibabaTokenPlanUsageError.networkError("Invalid response") + } + Self.log.info( + "Alibaba Token Plan Personal HTTP response", + metadata: [ + "api": api, + "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 + } + throw AlibabaTokenPlanUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + return data + } + + private static func personalAPIURL( + api: String, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) -> URL + { + let usageURL = self.resolveQuotaURL(region: region, environment: environment) + var components = URLComponents(url: usageURL, resolvingAgainstBaseURL: false)! + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == "api" } + queryItems.append(URLQueryItem(name: "api", value: api)) + components.queryItems = queryItems + return components.url ?? usageURL + } + + private static func personalAPIRequestBody( + api: String, + dataParameters: [String: String], + apiCookieHeader: String, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) throws -> Data + { + let dashboardURL = self.dashboardURL(region: region, environment: environment) + var cornerstone: [String: Any] = [ + "feTraceId": UUID().uuidString.lowercased(), + "feURL": dashboardURL.absoluteString, + "protocol": "V2", + "console": "ONE_CONSOLE", + "productCode": "p_efm", + "switchAgent": 1_233_135, + "switchUserType": 3, + "domain": dashboardURL.host ?? "", + "consoleSite": region.personalConsoleSite, + "userNickName": "", + "userPrincipalName": "", + "xsp_lang": "en-US", + ] + if let anonymousID = self.extractCookieValue(name: "cna", from: apiCookieHeader), !anonymousID.isEmpty { + cornerstone["X-Anonymous-Id"] = anonymousID + } + var apiData = dataParameters as [String: Any] + apiData["cornerstoneParam"] = cornerstone + let params: [String: Any] = [ + "Api": api, + "V": "1.0", + "Data": apiData, + ] + let paramsData = try JSONSerialization.data(withJSONObject: params) + guard let paramsJSON = String(data: paramsData, encoding: .utf8) else { + throw AlibabaTokenPlanUsageError.parseFailed("Could not encode request parameters") + } + + var body = URLComponents() + body.queryItems = [ + URLQueryItem(name: "product", value: self.personalConsoleProduct), + URLQueryItem(name: "action", value: region.personalAPIAction), + URLQueryItem(name: "region", value: region.currentRegionID), + URLQueryItem(name: "language", value: "en-US"), + URLQueryItem(name: "params", value: paramsJSON), + ] + return Data((body.percentEncodedQuery ?? "").utf8) + } + private static func subscriptionSummaryRequestBody(region: AlibabaTokenPlanAPIRegion, secToken: String?) -> Data { let paramsObject = ["ProductCode": region.tokenPlanProductCode] guard let paramsData = try? JSONSerialization.data(withJSONObject: paramsObject, options: []), @@ -411,13 +621,13 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return components.url ?? URL(string: region.dashboardOriginURLString)! } - private static func quotaURL(from rawHost: String) -> URL? { + private static func quotaURL(from rawHost: String, region: AlibabaTokenPlanAPIRegion) -> URL? { let cleaned = AlibabaTokenPlanSettingsReader.cleaned(rawHost) guard let cleaned else { return nil } guard let base = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) else { return nil } var components = URLComponents(url: base, resolvingAgainstBaseURL: false) let defaultComponents = URLComponents( - url: Self.defaultQuotaURL(region: .international), + url: Self.defaultQuotaURL(region: region), resolvingAgainstBaseURL: false) components?.path = "/data/api.json" components?.queryItems = defaultComponents?.queryItems @@ -485,15 +695,17 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return sourceHost == targetHost } - private static func throwIfErrorPayload(_ dictionary: [String: Any]) throws { + static func throwIfErrorPayload(_ dictionary: [String: Any]) throws { if self.parseBool(dictionary["successResponse"]) == false { if let statusCode = self.findFirstInt(forKeys: ["statusCode", "status_code", "code"], in: dictionary), statusCode == 401 || statusCode == 403 { throw AlibabaTokenPlanUsageError.invalidCredentials } - let code = self.findFirstString(forKeys: ["code", "status", "statusCode"], in: dictionary) - let message = self.findFirstString(forKeys: ["message", "msg", "statusMessage"], in: dictionary) ?? + let code = self.findFirstString(forKeys: ["errorCode", "code", "status", "statusCode"], in: dictionary) + let message = self.findFirstString( + forKeys: ["errorMsg", "message", "msg", "statusMessage"], + in: dictionary) ?? code ?? "request was not successful" if self.isLoginOrTokenError(code: code, message: message) { @@ -503,9 +715,10 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } if self.findBoolValues(forKeys: ["Success", "success"], in: dictionary).contains(false) { - let code = self.findFirstString(forKeys: ["Code", "code"], in: dictionary) - let message = self.findFirstString(forKeys: ["Message", "message", "msg", "Code", "code"], in: dictionary) - ?? "request was not successful" + let code = self.findFirstString(forKeys: ["errorCode", "Code", "code"], in: dictionary) + let message = self.findFirstString( + forKeys: ["errorMsg", "Message", "message", "msg", "Code", "code"], + in: dictionary) ?? "request was not successful" if self.isLoginOrTokenError(code: code, message: message) { throw AlibabaTokenPlanUsageError.loginRequired } @@ -526,8 +739,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { 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)? + let codeText = self.findFirstString( + forKeys: ["errorCode", "code", "status", "statusCode"], + in: dictionary)?.lowercased() + let messageText = self.findFirstString( + forKeys: ["errorMsg", "message", "msg", "statusMessage"], + in: dictionary)? .lowercased() if self.isLoginOrTokenError(code: codeText, message: messageText) { throw AlibabaTokenPlanUsageError.loginRequired @@ -867,10 +1084,18 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } 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 = 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 = OneConsoleJSON.string(raw) { let cleaned = value.replacingOccurrences(of: ",", with: "") return Double(cleaned) @@ -909,8 +1134,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func parseBool(_ raw: Any?) -> Bool? { - if let value = raw as? Bool { return value } - if let number = raw as? NSNumber { return number.boolValue } + 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": diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift index 36cee2a2b2..7ad96e868f 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift @@ -6,6 +6,12 @@ public struct AlibabaTokenPlanUsageSnapshot: Sendable { public let totalQuota: Double? public let remainingQuota: Double? public let resetsAt: Date? + public let fiveHourUsedPercent: Double? + public let fiveHourTotalQuota: Double? + public let fiveHourResetsAt: Date? + public let weeklyUsedPercent: Double? + public let weeklyTotalQuota: Double? + public let weeklyResetsAt: Date? public let updatedAt: Date public init( @@ -14,6 +20,12 @@ public struct AlibabaTokenPlanUsageSnapshot: Sendable { totalQuota: Double?, remainingQuota: Double?, resetsAt: Date?, + fiveHourUsedPercent: Double? = nil, + fiveHourTotalQuota: Double? = nil, + fiveHourResetsAt: Date? = nil, + weeklyUsedPercent: Double? = nil, + weeklyTotalQuota: Double? = nil, + weeklyResetsAt: Date? = nil, updatedAt: Date) { self.planName = planName @@ -21,13 +33,26 @@ public struct AlibabaTokenPlanUsageSnapshot: Sendable { self.totalQuota = totalQuota self.remainingQuota = remainingQuota self.resetsAt = resetsAt + self.fiveHourUsedPercent = fiveHourUsedPercent + self.fiveHourTotalQuota = fiveHourTotalQuota + self.fiveHourResetsAt = fiveHourResetsAt + self.weeklyUsedPercent = weeklyUsedPercent + self.weeklyTotalQuota = weeklyTotalQuota + self.weeklyResetsAt = weeklyResetsAt self.updatedAt = updatedAt } } extension AlibabaTokenPlanUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let primary: RateWindow? = Self.usedPercent( + let personalPrimary = self.fiveHourUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 5 * 60, + resetsAt: self.fiveHourResetsAt, + resetDescription: Self.quotaDetail(usedPercent: $0, total: self.fiveHourTotalQuota)) + } + let teamPrimary: RateWindow? = Self.usedPercent( used: self.usedQuota, total: self.totalQuota, remaining: self.remainingQuota).map { @@ -40,6 +65,14 @@ extension AlibabaTokenPlanUsageSnapshot { total: self.totalQuota, remaining: self.remainingQuota)) } + let primary = personalPrimary ?? teamPrimary + let secondary = self.weeklyUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 7 * 24 * 60, + resetsAt: self.weeklyResetsAt, + resetDescription: Self.quotaDetail(usedPercent: $0, total: self.weeklyTotalQuota)) + } let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -51,7 +84,7 @@ extension AlibabaTokenPlanUsageSnapshot { return UsageSnapshot( primary: primary, - secondary: nil, + secondary: secondary, tertiary: nil, providerCost: nil, updatedAt: self.updatedAt, @@ -85,6 +118,12 @@ extension AlibabaTokenPlanUsageSnapshot { return nil } + private static func quotaDetail(usedPercent: Double, total: Double?) -> String? { + guard let total, total > 0 else { return nil } + let used = total * usedPercent / 100 + return "\(Self.format(used)) / \(Self.format(total)) credits used" + } + private static func format(_ value: Double) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal diff --git a/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift index 9975e9368c..cd8b078c50 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift @@ -4,6 +4,44 @@ import Testing @testable import CodexBar struct AlibabaTokenPlanMenuCardModelTests { + @Test + func `Personal rolling windows use duration labels`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "Pro", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + fiveHourUsedPercent: 0, + weeklyUsedPercent: 10, + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.alibabatokenplan]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .alibabatokenplan, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5-hour", "7-day"]) + } + @Test func `monthly quota shows deficit and run out details`() throws { let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index c08bca81eb..30b0e4f3e8 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -2,6 +2,14 @@ import Foundation import Testing @testable import CodexBarCore +private func alibabaTokenPlanFixture(_ name: String) throws -> Data { + try Data( + contentsOf: #require(Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Fixtures/AlibabaTokenPlan"))) +} + struct AlibabaTokenPlanSettingsReaderTests { @Test func `cookie reads from environment`() { @@ -66,6 +74,25 @@ struct AlibabaTokenPlanSettingsReaderTests { #expect(url.absoluteString.contains("GetSubscriptionSummary")) #expect(url.absoluteString.contains("BssOpenAPI-V3")) } + + @Test + func `personal variants target their rolling window hosts without changing Team routes`() { + let internationalTeam = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .international) + let mainlandTeam = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainland) + let internationalPersonal = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .internationalPersonal) + let mainlandPersonal = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainlandPersonal) + + #expect(internationalTeam.host == "modelstudio.console.alibabacloud.com") + #expect(mainlandTeam.host == "bailian.console.aliyun.com") + #expect(internationalTeam.absoluteString.contains("GetSubscriptionSummary")) + #expect(mainlandTeam.absoluteString.contains("GetSubscriptionSummary")) + #expect(internationalPersonal.host == "bailian-singapore-cs.alibabacloud.com") + #expect(mainlandPersonal.host == "bailian-cs.console.aliyun.com") + #expect(internationalPersonal.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + #expect(mainlandPersonal.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + #expect(!internationalPersonal.absoluteString.contains("GetSubscriptionSummary")) + #expect(!mainlandPersonal.absoluteString.contains("GetSubscriptionSummary")) + } } struct AlibabaTokenPlanCookieHeaderTests { @@ -109,6 +136,56 @@ struct AlibabaTokenPlanCookieHeaderTests { #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) } + @Test + func `mainland Personal rebuilds cookies for the quota host`() throws { + let cookies = [ + self.cookie(name: "parent", value: "shared", domain: ".console.aliyun.com"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "quota_only", value: "quota", domain: "bailian-cs.console.aliyun.com"), + self.cookie(name: "collision", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "collision", value: "quota", domain: "bailian-cs.console.aliyun.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + region: .chinaMainlandPersonal)) + + #expect(headers.apiCookieHeader.contains("parent=shared")) + #expect(headers.apiCookieHeader.contains("quota_only=quota")) + #expect(headers.apiCookieHeader.contains("collision=quota")) + #expect(!headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.dashboardCookieHeader.contains("parent=shared")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.dashboardCookieHeader.contains("collision=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("quota_only=quota")) + } + + @Test + func `international Personal rebuilds cookies for the quota host`() throws { + let cookies = [ + self.cookie(name: "parent", value: "shared", domain: ".alibabacloud.com"), + self.cookie( + name: "dashboard_only", + value: "dashboard", + domain: "modelstudio.console.alibabacloud.com"), + self.cookie( + name: "quota_only", + value: "quota", + domain: "bailian-singapore-cs.alibabacloud.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + region: .internationalPersonal)) + + #expect(headers.apiCookieHeader.contains("parent=shared")) + #expect(headers.apiCookieHeader.contains("quota_only=quota")) + #expect(!headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.dashboardCookieHeader.contains("parent=shared")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("quota_only=quota")) + } + @Test func `cached token plan headers preserve URL scoping`() throws { let headers = AlibabaTokenPlanCookieHeaders( @@ -205,10 +282,106 @@ struct AlibabaTokenPlanUsageSnapshotTests { #expect(usage.primary == nil) #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") } + + @Test + func `emits a weekly window when 5 hour usage is absent`() { + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "Personal", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + weeklyUsedPercent: 10, + weeklyTotalQuota: 40000, + weeklyResetsAt: Date(timeIntervalSince1970: 1_785_234_900), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 10) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetDescription == "4,000 / 40,000 credits used") + } } @Suite(.serialized) struct AlibabaTokenPlanUsageParsingTests { + @Test + func `shared Personal parser maps issue documented rolling windows and tier quotas`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try AlibabaTokenPlanPersonalUsageParser.parse( + from: alibabaTokenPlanFixture("personal_usage"), + subscriptionData: alibabaTokenPlanFixture("personal_subscription"), + quotaConfigData: alibabaTokenPlanFixture("personal_quota_config"), + now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.planName == "Pro") + #expect(snapshot.fiveHourTotalQuota == 12000) + #expect(snapshot.weeklyTotalQuota == 40000) + #expect(abs((usage.primary?.usedPercent ?? -.infinity) - 0.09973083333333333) < 0.000_000_001) + #expect(usage.primary?.windowMinutes == 5 * 60) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_784_813_220)) + #expect(abs((usage.secondary?.usedPercent ?? -.infinity) - 0.03014725) < 0.000_000_001) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_785_234_900)) + #expect(usage.loginMethod(for: .alibabatokenplan) == "Pro") + } + + @Test + func `shared Personal parser accepts weekly only responses`() throws { + let json = """ + { + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per1WeekPercentage": 0.10007527475, + "per1WeekResetTime": 1785234900000 + } + } + } + }, + "successResponse": true + } + """ + + let snapshot = try AlibabaTokenPlanPersonalUsageParser.parse( + from: Data(json.utf8), + subscriptionData: nil, + quotaConfigData: nil, + now: Date(timeIntervalSince1970: 1_700_000_000)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(abs((usage.secondary?.usedPercent ?? -.infinity) - 10.007527475) < 0.000_000_001) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_785_234_900)) + } + + @Test + func `Personal login error maps to login required`() { + let json = """ + { + "data": { + "success": false, + "errorCode": "BailianGateway.Login.NotLogined", + "errorMsg": "BailianGateway.Login.NotLogined" + }, + "httpStatusCode": "200" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanPersonalUsageParser.parse( + from: Data(json.utf8), + subscriptionData: nil, + quotaConfigData: nil, + now: Date(timeIntervalSince1970: 1_700_000_000)) + } + } + @Test func `parses subscription summary payload`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -372,6 +545,59 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `mainland Personal fetch uses quota host cookies without requiring SEC token`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + let usageBody = try #require(String(data: alibabaTokenPlanFixture("personal_usage"), encoding: .utf8)) + let subscriptionBody = try #require( + String(data: alibabaTokenPlanFixture("personal_subscription"), encoding: .utf8)) + let quotaBody = try #require( + String(data: alibabaTokenPlanFixture("personal_quota_config"), encoding: .utf8)) + + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.host == "bailian-cs.console.aliyun.com") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Cookie") == "quota_only=quota") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") + let body = Self.requestBodyString(from: request) + #expect(!body.contains("sec_token")) + #expect(body.removingPercentEncoding?.contains("cornerstoneParam") == true) + + let api = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "api" })? + .value + switch api { + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage": + return Self.makeResponse(url: url, body: usageBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription": + #expect(body.removingPercentEncoding?.contains("sfm_tokenplansolo_public_cn") == true) + return Self.makeResponse(url: url, body: subscriptionBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config": + return Self.makeResponse(url: url, body: quotaBody, statusCode: 200) + default: + throw URLError(.unsupportedURL) + } + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard", + region: .chinaMainlandPersonal, + environment: [:], + session: session) + + #expect(snapshot.planName == "Pro") + #expect(snapshot.toUsageSnapshot().primary != nil) + #expect(snapshot.toUsageSnapshot().secondary != nil) + } + @Test func `SEC token preflight falls back to user info`() async throws { defer { @@ -922,6 +1148,8 @@ final class AlibabaTokenPlanStubURLProtocol: URLProtocol { 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 == "bailian-singapore-cs.alibabacloud.com" || host == "alibaba-token-plan.test" || host == "session-token.test" } diff --git a/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_quota_config.json b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_quota_config.json new file mode 100644 index 0000000000..ff9d973a86 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_quota_config.json @@ -0,0 +1,27 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "lite": { + "five_hour": 700, + "weekly": 2500 + }, + "standard": { + "five_hour": 3000, + "weekly": 10000 + }, + "pro": { + "five_hour": 12000, + "weekly": 40000 + } + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true +} diff --git a/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_subscription.json b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_subscription.json new file mode 100644 index 0000000000..1f0c689c9a --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_subscription.json @@ -0,0 +1,22 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "instanceCode": "sfm_tokenplansolo_public_cn-redacted", + "specCode": "pro", + "status": "VALID", + "remainingDays": 29, + "autoRenewFlag": false, + "startTime": 1784622404000, + "endTime": 1787328000000 + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true +} diff --git a/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_usage.json b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_usage.json new file mode 100644 index 0000000000..a7f05b3d56 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_usage.json @@ -0,0 +1,19 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per5HourPercentage": 0.0009973083333333333, + "per5HourResetTime": 1784813220000, + "per1WeekPercentage": 0.0003014725, + "per1WeekResetTime": 1785234900000 + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index c67c2c3b0d..b3a1c42bed 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -550,8 +550,10 @@ extension ProviderSettingsDescriptorTests { let implementation = AlibabaTokenPlanProviderImplementation() let pickers = implementation.settingsPickers(context: context) let fields = implementation.settingsFields(context: context) + let regionPicker = try #require(pickers.first(where: { $0.id == "alibaba-token-plan-region" })) #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) + #expect(Set(regionPicker.options.map(\.id)) == ["intl", "cn", "intl-personal", "cn-personal"]) #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) } diff --git a/docs/alibaba-token-plan.md b/docs/alibaba-token-plan.md index 1d8884605e..5f3a8145a0 100644 --- a/docs/alibaba-token-plan.md +++ b/docs/alibaba-token-plan.md @@ -1,5 +1,5 @@ --- -summary: "Alibaba Token Plan provider notes: Bailian cookie auth, subscription summary endpoint, and setup." +summary: "Alibaba Token Plan provider notes: Team and Personal/Solo variants, cookie auth, and setup." read_when: - Adding or modifying the Alibaba Token Plan provider - Debugging Alibaba Token Plan cookie import or subscription summary fetching @@ -8,11 +8,12 @@ read_when: # Alibaba Token Plan Provider -The Alibaba Token Plan provider tracks Bailian token-plan credits from the Alibaba Cloud console. +The Alibaba Token Plan provider tracks Team credits and Personal/Solo rolling-window usage from the Alibaba Cloud console. ## Features - **Token-plan usage display**: Shows used, total, and remaining token-plan credits when Bailian returns quota totals. +- **Personal/Solo windows**: Shows 5-hour and 7-day usage, reset times, and tier-specific quota totals. - **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. - **Expiry awareness**: Shows the nearest token-plan expiration date as the reset time when the subscription summary includes it. @@ -20,27 +21,32 @@ The Alibaba Token Plan provider tracks Bailian token-plan credits from the Aliba 1. Open **Settings -> Providers** 2. Enable **Alibaba Token Plan** -3. Leave **Cookie source** on **Auto** (recommended) +3. Choose the matching **Gateway region** Team or Personal/Solo variant +4. Leave **Cookie source** on **Auto** (recommended) ### Manual cookie import (optional) -1. Open `https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan` -2. Copy a `Cookie:` header from your browser's Network tab +1. Open the Token Plan page using **Open Token Plan** in settings +2. Copy a `Cookie:` header from the quota request in your browser's Network tab. For Personal/Solo, use the + `.../tokenplan/personal/api/v2/usage` request so the header is scoped to the quota host. 3. Paste it into **Alibaba Token Plan -> Cookie source -> Manual** ## How it works -- Fetches `POST https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3&_tag=` -- Sends form-encoded fields for `product=BssOpenAPI-V3`, `action=GetSubscriptionSummary`, `region=cn-beijing`, and `params={"ProductCode":"sfm_tokenplanteams_dp_cn"}` -- Uses Alibaba/Bailian login cookies, with `sec_token` added when it can be resolved from the dashboard page -- Parses `TotalValue`, `TotalSurplusValue`, `TotalCount`, and `NearestExpireDate` from the subscription summary response +- Team variants fetch `GetSubscriptionSummary` from the selected international or mainland console and parse the + credit pool without probing Personal endpoints. +- Personal/Solo variants fetch `usage`, `subscription`, and `quota-config` from the rolling-window API. International + uses `bailian-singapore-cs.alibabacloud.com`; mainland uses `bailian-cs.console.aliyun.com`. +- Browser cookies are rebuilt independently for the dashboard and quota hosts. Personal/Solo requests use the + quota-host cookie header and support cookie-only auth without requiring `sec_token`. +- Config region values are `intl` and `cn` for Team, or `intl-personal` and `cn-personal` for Personal/Solo. - Supports `ALIBABA_TOKEN_PLAN_HOST` and `ALIBABA_TOKEN_PLAN_QUOTA_URL` for testing endpoint overrides ## Limitations - Alibaba Token Plan currently supports the Bailian web-cookie path only - API-key auth, token cost summaries, and automatic status polling are not supported -- The default endpoint is the China mainland Bailian token-plan subscription summary +- Live provider auth is not exercised by the test suite; endpoint changes rely on reporter confirmation after release ## Troubleshooting diff --git a/docs/providers.md b/docs/providers.md index 3779d86208..7ffa07720a 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -219,9 +219,12 @@ scan fails, while provider/account configuration changes replace obsolete result - Details: `docs/alibaba-coding-plan.md`. ## Alibaba Token Plan -- Web mode posts to the Bailian `GetSubscriptionSummary` endpoint with form-encoded params and optional `sec_token`. +- Explicit Team variants post to `GetSubscriptionSummary`; explicit Personal/Solo variants fetch the 5-hour and + weekly rolling windows plus subscription/quota metadata without probing across plan types. - Cookie sources: browser import (`auto`), manual Cookie header, or `ALIBABA_TOKEN_PLAN_COOKIE`. -- Default quota URL: `https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3`. +- Region values: `intl` / `cn` for Team and `intl-personal` / `cn-personal` for Personal/Solo. +- Personal quota hosts: `bailian-singapore-cs.alibabacloud.com` (international) and + `bailian-cs.console.aliyun.com` (mainland), with cookies scoped independently from the dashboard host. - Host overrides: `ALIBABA_TOKEN_PLAN_HOST` or `ALIBABA_TOKEN_PLAN_QUOTA_URL`. - Status: `https://status.aliyun.com` (link only, no auto-polling). - Details: `docs/alibaba-token-plan.md`.