diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf9e1a09a..8d4e61bef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,12 @@ ## 0.45.2 — Unreleased +### Added +- Alibaba Token Plan: support the Qwen Cloud personal plan (`region: "qwen"`), which reports rolling 5-hour and weekly windows instead of a monthly credit pool (#2328). This region is manual-cookie only. + ### Fixed +- Alibaba Token Plan: allow the provider to run on Linux when cookies are configured manually; only browser cookie import needs macOS (#2328). +- Alibaba Token Plan: escape `+` in form-encoded request bodies so a `sec_token` containing one is no longer decoded as a space. ## 0.45.1 — 2026-07-19 diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 56be5c3144..ed95cdda1b 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -713,6 +713,15 @@ extension CodexBarCLI { { return false } + if provider == .alibabatokenplan, + settings?.alibabaTokenPlan?.cookieSource == .manual || + environment.map({ AlibabaTokenPlanSettingsReader.cookieHeader(environment: $0) != nil }) == true + { + // The quota fetch is plain URLSession + cookies; only browser import needs macOS. + // ALIBABA_TOKEN_PLAN_COOKIE is honored regardless of the configured cookie source, + // so it has to open the gate on its own too. + return false + } if provider == .ollama, sourceMode == .auto { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift index ae7453f901..32034de553 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift @@ -3,6 +3,7 @@ import Foundation public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { case international = "intl" case chinaMainland = "cn" + case qwenCloudPersonal = "qwen" public var displayName: String { switch self { @@ -10,6 +11,8 @@ public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { "International (modelstudio.console.alibabacloud.com)" case .chinaMainland: "China mainland (bailian.console.aliyun.com)" + case .qwenCloudPersonal: + "Qwen Cloud personal (home.qwencloud.com)" } } @@ -19,6 +22,19 @@ public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { "https://modelstudio.console.alibabacloud.com" case .chinaMainland: "https://bailian.console.aliyun.com" + case .qwenCloudPersonal: + "https://home.qwencloud.com" + } + } + + /// Qwen Cloud serves the console shell and the data gateway from different hosts; the + /// other regions post back to the console host itself. + public var quotaAPIBaseURLString: String { + switch self { + case .international, .chinaMainland: + self.gatewayBaseURLString + case .qwenCloudPersonal: + "https://cs-data.qwencloud.com" } } @@ -33,27 +49,46 @@ 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 .qwenCloudPersonal: + URL(string: "https://home.qwencloud.com/billing/subscription/token-plan-individual")! } } public var currentRegionID: String { switch self { - case .international: + case .international, .qwenCloudPersonal: "ap-southeast-1" case .chinaMainland: "cn-beijing" } } - public var tokenPlanProductCode: String { + /// Team-edition subscription summaries are keyed by commodity code. The personal plan + /// uses a REST-style gateway that takes no product code, so this is nil there. + public var tokenPlanProductCode: String? { switch self { case .international: "sfm_tokenplanteams_dp_intl" case .chinaMainland: "sfm_tokenplanteams_dp_cn" + case .qwenCloudPersonal: + nil } } + /// The personal plan reports rolling 5-hour/weekly windows instead of a credit pool, + /// which changes the gateway payload, the response schema, and the rendered windows. + public var usesPersonalTokenPlanAPI: Bool { + self == .qwenCloudPersonal + } + + /// Qwen Cloud logs in under its own domain with its own ticket cookie name + /// (`login_qwencloud_ticket`), neither of which the shared Alibaba browser importer + /// recognises, so this region requires a manually supplied cookie header. + public var supportsBrowserCookieImport: Bool { + self != .qwenCloudPersonal + } + public var cookieCacheScope: CookieHeaderCache.Scope { .providerVariant("alibaba-token-plan.\(self.rawValue)") } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift index 14a99af975..7030e02910 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift @@ -83,12 +83,15 @@ enum AlibabaTokenPlanCookieHeader { region: AlibabaTokenPlanAPIRegion = .international, environment: [String: String] = ProcessInfo.processInfo.environment) -> AlibabaTokenPlanCookieHeaders? { - guard let apiHeader = self.header( - from: cookies, - targetURL: AlibabaTokenPlanUsageFetcher.resolveQuotaURL(region: region, environment: environment)), - let dashboardHeader = self.header( - from: cookies, - targetURL: AlibabaTokenPlanUsageFetcher.dashboardURL(region: region, environment: environment)) + let quotaURL = AlibabaTokenPlanUsageFetcher.resolveQuotaURL(region: region, environment: environment) + let dashboardURL = AlibabaTokenPlanUsageFetcher.dashboardURL(region: region, environment: environment) + // Qwen Cloud serves the console and the data gateway from sibling hosts, and host-scoped + // login cookies only match one of them; the browser sends both, so union the two sets. + // Where both hosts are the same (intl/cn) this must stay a strict no-op, otherwise + // dashboard path-scoped cookies would start leaking into the api header. + let apiTargets = quotaURL.host == dashboardURL.host ? [quotaURL] : [quotaURL, dashboardURL] + guard let apiHeader = self.header(from: cookies, targetURLs: apiTargets), + let dashboardHeader = self.header(from: cookies, targetURL: dashboardURL) else { return nil } @@ -96,12 +99,16 @@ enum AlibabaTokenPlanCookieHeader { } static func header(from cookies: [HTTPCookie], targetURL: URL) -> String? { + self.header(from: cookies, targetURLs: [targetURL]) + } + + static func header(from cookies: [HTTPCookie], targetURLs: [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 } + guard targetURLs.contains(where: { self.matchesRequestURL(cookie: cookie, url: $0) }) else { continue } if let existing = byName[cookie.name] { if self.cookieSortKey(for: cookie) >= self.cookieSortKey(for: existing) { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalAPI.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalAPI.swift new file mode 100644 index 0000000000..a55ed6e07f --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalAPI.swift @@ -0,0 +1,227 @@ +import Foundation + +/// Request/response handling for the Qwen Cloud personal ("solo") token plan. +/// +/// This edition is served by a different console gateway than the team edition: the +/// action is a generic ASPN passthrough (`IntlBroadScopeAspnGateway`) that tunnels a +/// REST path in the `params` payload, and the useful data is nested four levels deep +/// under `data.DataV2.data.data`. +enum AlibabaTokenPlanPersonalAPI { + enum Endpoint: String, CaseIterable { + case usage + case subscription + case quotaConfig = "quota-config" + + var apiName: String { + "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/\(self.rawValue)" + } + } + + static let product = "sfm_bailian" + static let action = "IntlBroadScopeAspnGateway" + + static func requestURL(baseURLString: String, endpoint: Endpoint) -> URL? { + guard var components = URLComponents(string: baseURLString) else { return nil } + components.path = "/data/api.json" + components.queryItems = [ + URLQueryItem(name: "product", value: Self.product), + URLQueryItem(name: "action", value: Self.action), + URLQueryItem(name: "api", value: endpoint.apiName), + ] + return components.url + } + + static func requestBody( + endpoint: Endpoint, + region: AlibabaTokenPlanAPIRegion, + secToken: String?) -> Data + { + let params: [String: Any] = [ + "Api": endpoint.apiName, + "Data": [ + "cornerstoneParam": [ + "domain": "home.qwencloud.com", + "consoleSite": "QWENCLOUD", + "console": "ONE_CONSOLE", + "xsp_lang": "en-US", + "protocol": "V2", + "productCode": "p_efm", + ], + ], + "V": "1.0", + ] + guard let paramsData = try? JSONSerialization.data(withJSONObject: params, options: [.sortedKeys]), + let paramsString = String(data: paramsData, encoding: .utf8) + else { + return Data() + } + + var queryItems = [ + URLQueryItem(name: "product", value: Self.product), + URLQueryItem(name: "action", value: Self.action), + ] + if let secToken, !secToken.isEmpty { + queryItems.append(URLQueryItem(name: "sec_token", value: secToken)) + } + queryItems.append(URLQueryItem(name: "region", value: region.currentRegionID)) + queryItems.append(URLQueryItem(name: "params", value: paramsString)) + return AlibabaTokenPlanUsageFetcher.formEncodedBody(queryItems) + } + + /// Unwraps the `data.DataV2.data.data` envelope and surfaces gateway-level failures. + /// + /// Stale sessions come back as HTTP 200 with an error code in the body, so failures are + /// classified through the same login/token detector the team edition uses — otherwise the + /// descriptor never recognises them as credential failures and never re-imports cookies. + static func unwrapPayload(from data: Data) throws -> [String: Any] { + guard !data.isEmpty else { + throw AlibabaTokenPlanUsageError.parseFailed("Empty response body") + } + guard let root = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + if AlibabaTokenPlanUsageFetcher.isLikelyLoginHTML(data) { + throw AlibabaTokenPlanUsageError.loginRequired + } + throw AlibabaTokenPlanUsageError.parseFailed("Invalid JSON response") + } + // The root mirrors the HTTP status in `code` and carries no detail of its own, so only + // consult it when there is no envelope left to descend into. + guard let outer = root["data"] as? [String: Any] else { + try Self.throwIfFailed(root, codeKeys: ["errorCode", "code"], messageKeys: ["errorMsg", "message"]) + throw Self.missingEnvelope("Missing data envelope", root: root) + } + try Self.throwIfFailed(outer, codeKeys: ["errorCode", "code"], messageKeys: ["errorMsg", "message"]) + guard let dataV2 = outer["DataV2"] as? [String: Any], + let inner = dataV2["data"] as? [String: Any] + else { + throw Self.missingEnvelope("Missing DataV2 payload", root: root) + } + try Self.throwIfFailed(inner, codeKeys: ["code", "errorCode"], messageKeys: ["msg", "message"]) + guard let payload = inner["data"] as? [String: Any] else { + throw Self.missingEnvelope("Missing payload body", root: root) + } + return payload + } + + /// An envelope can go missing simply because the gateway replaced it with a login/auth error + /// that carries no `success` flag. Sweep the whole body before giving up, so those still + /// classify as credential failures and trigger a cookie re-import instead of a parse error. + private static func missingEnvelope(_ reason: String, root: [String: Any]) -> AlibabaTokenPlanUsageError { + let codes = ["errorCode", "code", "errorMsg", "msg", "message", "ret"].compactMap { key in + AlibabaTokenPlanUsageFetcher.findFirstString(forKeys: [key], in: root) + } + if AlibabaTokenPlanUsageFetcher.isLoginOrTokenError(code: codes.joined(separator: " "), message: nil) { + return .loginRequired + } + return .parseFailed(reason) + } + + private static func throwIfFailed( + _ dictionary: [String: Any], + codeKeys: [String], + messageKeys: [String]) throws + { + let successKeys = ["success", "successResponse"] + let failed = successKeys.contains { key in + dictionary[key].map { AlibabaTokenPlanUsageFetcher.parseBool($0) == false } ?? false + } + guard failed else { return } + let code = codeKeys.lazy.compactMap { AlibabaTokenPlanUsageFetcher.parseString(dictionary[$0]) }.first + let message = messageKeys.lazy.compactMap { AlibabaTokenPlanUsageFetcher.parseString(dictionary[$0]) }.first + if AlibabaTokenPlanUsageFetcher.isLoginOrTokenError(code: code, message: message) { + throw AlibabaTokenPlanUsageError.loginRequired + } + throw AlibabaTokenPlanUsageError.apiError(message ?? code ?? "Unknown gateway error") + } + + struct UsagePayload: Sendable, Equatable { + let fiveHourPercent: Double + let fiveHourResetsAt: Date? + let weeklyPercent: Double + let weeklyResetsAt: Date? + } + + /// Percentages arrive as fractions of 1.0 (0.0105 == 1.05% consumed). + static func parseUsage(from data: Data) throws -> UsagePayload { + let payload = try self.unwrapPayload(from: data) + guard let fiveHour = self.double(payload["per5HourPercentage"]), + let weekly = self.double(payload["per1WeekPercentage"]) + else { + throw AlibabaTokenPlanUsageError.parseFailed( + "Missing usage percentages (keys: \(payload.keys.sorted().joined(separator: ",")))") + } + return UsagePayload( + fiveHourPercent: fiveHour * 100, + fiveHourResetsAt: self.date(payload["per5HourResetTime"]), + weeklyPercent: weekly * 100, + weeklyResetsAt: self.date(payload["per1WeekResetTime"])) + } + + struct SubscriptionPayload: Sendable, Equatable { + let specCode: String? + let status: String? + } + + static func parseSubscription(from data: Data) throws -> SubscriptionPayload { + let payload = try self.unwrapPayload(from: data) + return SubscriptionPayload( + specCode: payload["specCode"] as? String, + status: payload["status"] as? String) + } + + /// Maps tier name (`lite`, `standard`, `pro`) to that tier's credit ceilings. Keys are + /// lowercased so lookups by `specCode` are case-insensitive. + static func parseQuotaConfig(from data: Data) throws -> [String: (fiveHour: Double, weekly: Double)] { + let payload = try self.unwrapPayload(from: data) + var result: [String: (fiveHour: Double, weekly: Double)] = [:] + for (tier, value) in payload { + guard let entry = value as? [String: Any], + let fiveHour = self.double(entry["five_hour"]), + let weekly = self.double(entry["weekly"]) + else { continue } + result[tier.lowercased()] = (fiveHour: fiveHour, weekly: weekly) + } + guard !result.isEmpty else { + throw AlibabaTokenPlanUsageError.parseFailed( + "No tier ceilings in quota-config (keys: \(payload.keys.sorted().joined(separator: ",")))") + } + return result + } + + static func tier( + for specCode: String?, + in config: [String: (fiveHour: Double, weekly: Double)]?) -> (fiveHour: Double, weekly: Double)? + { + guard let specCode, let config else { return nil } + return config[specCode.lowercased()] + } + + private static let tierDisplayNames = ["lite": "Lite", "standard": "Standard", "pro": "Pro"] + + static func planName(specCode: String?, status: String? = nil) -> String { + var name = "Token Plan" + if let specCode, !specCode.isEmpty { + name += " \(self.tierDisplayNames[specCode.lowercased()] ?? specCode)" + } + // Surface a lapsed subscription; windows keep reporting after a plan stops being VALID. + if let status, !status.isEmpty, status.uppercased() != "VALID" { + name += " (\(status.uppercased()))" + } + return name + } + + private static func double(_ raw: Any?) -> Double? { + switch raw { + case let value as Double: value + case let value as Int: Double(value) + case let value as NSNumber: value.doubleValue + case let value as String: Double(value) + default: nil + } + } + + /// Timestamps are epoch milliseconds. + private static func date(_ raw: Any?) -> Date? { + guard let millis = self.double(raw), millis > 0 else { return nil } + return Date(timeIntervalSince1970: millis / 1000) + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift index ddf7970adc..b73463ea88 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -195,6 +195,13 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { return headers } + guard region.supportsBrowserCookieImport else { + throw AlibabaTokenPlanSettingsError.missingCookie( + details: "The Qwen Cloud personal token plan does not support browser cookie import. " + + "Open https://home.qwencloud.com/billing/subscription/token-plan-individual, copy a " + + "Cookie: header from the Network tab, and paste it as a manual cookie header.") + } + do { var importLog: [String] = [] let session = try AlibabaCodingPlanCookieImporter.importSession( diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift index 1074d6c252..96bbe51be3 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift @@ -9,6 +9,8 @@ public struct AlibabaTokenPlanSettingsReader: Sendable { allowedHosts: [ "modelstudio.console.alibabacloud.com", "bailian.console.aliyun.com", + "home.qwencloud.com", + "cs-data.qwencloud.com", ]) public static func cookieHeader( diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 27aee5a635..d76ce4574b 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -134,15 +134,129 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "secTokenSource": secToken == nil ? "missing" : "resolved", ]) + defer { + 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": "\(apiRedirectDiagnostics.redirects.count)", + "items": apiRedirectDiagnostics.redirects.joined(separator: " | "), + ]) + } + } + + if region.usesPersonalTokenPlanAPI { + return try await self.fetchPersonalUsage( + apiCookieHeader: normalizedAPIHeader, + region: region, + environment: environment, + gatewayBaseURL: url, + secToken: secToken, + now: now, + session: apiSession) + } + + let data = try await self.performGatewayRequest( + url: url, + body: self.subscriptionSummaryRequestBody(region: region, secToken: secToken), + cookieHeader: normalizedAPIHeader, + region: region, + environment: environment, + session: apiSession) + return try self.parseUsageSnapshot(from: data, now: now) + } + + /// The personal plan splits its data across three gateway calls. Usage is required; + /// the subscription tier and its credit ceilings only enrich the display, so they are + /// fetched best-effort and their failure must not hide an otherwise valid reading. + private static func fetchPersonalUsage( + apiCookieHeader: String, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String], + gatewayBaseURL: URL, + secToken: String?, + now: Date, + session: URLSession) async throws -> AlibabaTokenPlanUsageSnapshot + { + // Preserve any ALIBABA_TOKEN_PLAN_HOST / _QUOTA_URL override already applied upstream. + var baseComponents = URLComponents() + baseComponents.scheme = gatewayBaseURL.scheme + baseComponents.host = gatewayBaseURL.host + baseComponents.port = gatewayBaseURL.port + let baseURLString = baseComponents.url?.absoluteString ?? region.quotaAPIBaseURLString + + func request(_ endpoint: AlibabaTokenPlanPersonalAPI.Endpoint) async throws -> Data { + guard let url = AlibabaTokenPlanPersonalAPI.requestURL( + baseURLString: baseURLString, + endpoint: endpoint) + else { + throw AlibabaTokenPlanUsageError.parseFailed("Could not build \(endpoint.rawValue) URL") + } + return try await self.performGatewayRequest( + url: url, + body: AlibabaTokenPlanPersonalAPI.requestBody( + endpoint: endpoint, + region: region, + secToken: secToken), + cookieHeader: apiCookieHeader, + region: region, + environment: environment, + session: session) + } + + let usage = try AlibabaTokenPlanPersonalAPI.parseUsage(from: try await request(.usage)) + + let subscription = try? AlibabaTokenPlanPersonalAPI.parseSubscription(from: try await request(.subscription)) + // The tier table is only meaningful once the subscription names a tier. + let quotaConfig: [String: (fiveHour: Double, weekly: Double)]? + if subscription?.specCode != nil { + quotaConfig = try? AlibabaTokenPlanPersonalAPI.parseQuotaConfig(from: try await request(.quotaConfig)) + } else { + quotaConfig = nil + } + let tier = AlibabaTokenPlanPersonalAPI.tier(for: subscription?.specCode, in: quotaConfig) + + return AlibabaTokenPlanUsageSnapshot( + planName: AlibabaTokenPlanPersonalAPI.planName( + specCode: subscription?.specCode, + status: subscription?.status), + quota: .rollingWindows( + fiveHour: AlibabaTokenPlanRollingWindow( + usedPercent: usage.fiveHourPercent, + totalCredits: tier?.fiveHour, + resetsAt: usage.fiveHourResetsAt), + weekly: AlibabaTokenPlanRollingWindow( + usedPercent: usage.weeklyPercent, + totalCredits: tier?.weekly, + resetsAt: usage.weeklyResetsAt)), + updatedAt: now) + } + + private static func performGatewayRequest( + url: URL, + body: Data, + cookieHeader: String, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String], + session: URLSession) async throws -> Data + { var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 20 - request.httpBody = self.subscriptionSummaryRequestBody(region: region, secToken: secToken) + request.httpBody = body 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(cookieHeader, forHTTPHeaderField: "Cookie") + if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: cookieHeader) ?? + self.extractCookieValue(name: "csrf", from: cookieHeader) { request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") @@ -157,7 +271,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let data: Data let response: URLResponse do { - (data, response) = try await apiSession.data(for: request) + (data, response) = try await session.data(for: request) } catch { Self.log.error( "Alibaba Token Plan request failed", @@ -167,22 +281,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { ]) throw AlibabaTokenPlanUsageError.networkError(error.localizedDescription) } - 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": "\(apiRedirectDiagnostics.redirects.count)", - "items": apiRedirectDiagnostics.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") @@ -201,8 +299,17 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { Self.log.error("Alibaba Token Plan returned HTTP \(httpResponse.statusCode)") throw AlibabaTokenPlanUsageError.apiError("HTTP \(httpResponse.statusCode)") } + return data + } - return try self.parseUsageSnapshot(from: data, now: now) + /// `URLComponents.percentEncodedQuery` leaves `+` literal, which an + /// `application/x-www-form-urlencoded` reader decodes as a space — corrupting any value that + /// contains one (`sec_token` regularly does). + static func formEncodedBody(_ queryItems: [URLQueryItem]) -> Data { + var components = URLComponents() + components.queryItems = queryItems + let encoded = (components.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B") + return Data(encoded.utf8) } static func resolveQuotaURL( @@ -225,7 +332,14 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } static func defaultQuotaURL(region: AlibabaTokenPlanAPIRegion) -> URL { - var components = URLComponents(string: region.gatewayBaseURLString)! + if region.usesPersonalTokenPlanAPI, + let url = AlibabaTokenPlanPersonalAPI.requestURL( + baseURLString: region.quotaAPIBaseURLString, + endpoint: .usage) + { + return url + } + var components = URLComponents(string: region.quotaAPIBaseURLString)! components.path = "/data/api.json" components.queryItems = [ URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), @@ -273,22 +387,19 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return AlibabaTokenPlanUsageSnapshot( planName: planName, - usedQuota: used, - totalQuota: total, - remainingQuota: remaining, - resetsAt: resetsAt, + quota: .creditPool(used: used, total: total, remaining: remaining, resetsAt: resetsAt), updatedAt: now) } private static func subscriptionSummaryRequestBody(region: AlibabaTokenPlanAPIRegion, secToken: String?) -> Data { - let paramsObject = ["ProductCode": region.tokenPlanProductCode] + guard let productCode = region.tokenPlanProductCode else { return Data() } + let paramsObject = ["ProductCode": productCode] 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: "product", value: Self.bssServiceCode), URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), @@ -298,8 +409,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { if let secToken, !secToken.isEmpty { queryItems.append(URLQueryItem(name: "sec_token", value: secToken)) } - components.queryItems = queryItems - return Data((components.percentEncodedQuery ?? "").utf8) + return Self.formEncodedBody(queryItems) } private static func resolveSECToken( @@ -534,7 +644,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } } - private static func isLoginOrTokenError(code: String?, message: String?) -> Bool { + static func isLoginOrTokenError(code: String?, message: String?) -> Bool { let combined = [code, message] .compactMap { $0?.lowercased() } .joined(separator: " ") @@ -667,7 +777,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return "topKeys=\(topKeys.joined(separator: ",")) dataKeys=\(dataKeys.joined(separator: ","))" } - private static func isLikelyLoginHTML(_ data: Data) -> Bool { + static func isLikelyLoginHTML(_ data: Data) -> Bool { guard let text = String(data: data, encoding: .utf8)?.lowercased() else { return false } return text.contains(" String? { + 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]) { @@ -882,7 +992,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return nil } - private static func parseString(_ raw: Any?) -> String? { + 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 @@ -914,7 +1024,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return nil } - private static func parseBool(_ raw: Any?) -> Bool? { + 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 } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift index 36cee2a2b2..219b169b39 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift @@ -1,44 +1,65 @@ import Foundation +/// One rolling usage window of the personal token plan. +public struct AlibabaTokenPlanRollingWindow: Sendable { + public let usedPercent: Double + public let totalCredits: Double? + public let resetsAt: Date? + + public init(usedPercent: Double, totalCredits: Double?, resetsAt: Date?) { + self.usedPercent = usedPercent + self.totalCredits = totalCredits + self.resetsAt = resetsAt + } + + public var usedCredits: Double? { + self.totalCredits.map { $0 * self.usedPercent / 100 } + } +} + +/// The two editions report fundamentally different quota shapes, so they are modelled as +/// separate cases rather than a bag of optionals where only some combinations are valid. +public enum AlibabaTokenPlanQuota: Sendable { + /// Team edition: a monthly credit pool. + case creditPool(used: Double?, total: Double?, remaining: Double?, resetsAt: Date?) + /// Personal edition: rolling 5-hour and weekly windows. + case rollingWindows(fiveHour: AlibabaTokenPlanRollingWindow, weekly: AlibabaTokenPlanRollingWindow) +} + 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 quota: AlibabaTokenPlanQuota public let updatedAt: Date - public init( - planName: String?, - usedQuota: Double?, - totalQuota: Double?, - remainingQuota: Double?, - resetsAt: Date?, - updatedAt: Date) - { + public init(planName: String?, quota: AlibabaTokenPlanQuota, updatedAt: Date) { self.planName = planName - self.usedQuota = usedQuota - self.totalQuota = totalQuota - self.remainingQuota = remainingQuota - self.resetsAt = resetsAt + self.quota = quota self.updatedAt = updatedAt } } extension AlibabaTokenPlanUsageSnapshot { + private static let fiveHourWindowMinutes = 5 * 60 + private static let weeklyWindowMinutes = 7 * 24 * 60 + private static let monthlyWindowMinutes = 30 * 24 * 60 + 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 primary: RateWindow? + let secondary: RateWindow? + + switch self.quota { + case let .creditPool(used, total, remaining, resetsAt): + primary = Self.usedPercent(used: used, total: total, remaining: remaining).map { + RateWindow( + usedPercent: $0, + windowMinutes: Self.monthlyWindowMinutes, + resetsAt: resetsAt, + resetDescription: Self.quotaDetail(used: used, total: total, remaining: remaining)) + } + secondary = nil + case let .rollingWindows(fiveHour, weekly): + primary = Self.rateWindow(from: fiveHour, windowMinutes: Self.fiveHourWindowMinutes) + secondary = Self.rateWindow(from: weekly, windowMinutes: Self.weeklyWindowMinutes) } let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -51,13 +72,29 @@ extension AlibabaTokenPlanUsageSnapshot { return UsageSnapshot( primary: primary, - secondary: nil, + secondary: secondary, tertiary: nil, providerCost: nil, updatedAt: self.updatedAt, identity: identity) } + private static func rateWindow( + from window: AlibabaTokenPlanRollingWindow, + windowMinutes: Int) -> RateWindow + { + let detail: String? = if let total = window.totalCredits, total > 0, let used = window.usedCredits { + "\(self.format(used)) / \(self.format(total)) credits used" + } else { + nil + } + return RateWindow( + usedPercent: max(0, min(window.usedPercent, 100)), + windowMinutes: windowMinutes, + resetsAt: window.resetsAt, + resetDescription: detail) + } + 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 { diff --git a/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift index 9975e9368c..41b3555b84 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift @@ -9,10 +9,11 @@ struct AlibabaTokenPlanMenuCardModelTests { let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z let snapshot = AlibabaTokenPlanUsageSnapshot( planName: "TOKEN PLAN", - usedQuota: 900, - totalQuota: 1000, - remainingQuota: nil, - resetsAt: now.addingTimeInterval(6 * 24 * 3600), + quota: .creditPool( + used: 900, + total: 1000, + remaining: nil, + resetsAt: now.addingTimeInterval(6 * 24 * 3600)), updatedAt: now) .toUsageSnapshot() let metadata = try #require(ProviderDefaults.metadata[.alibabatokenplan]) diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 1db85ef8a0..ee05abaa1f 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -175,10 +175,7 @@ struct AlibabaTokenPlanUsageSnapshotTests { let reset = Date(timeIntervalSince1970: 1_700_100_000) let snapshot = AlibabaTokenPlanUsageSnapshot( planName: "TOKEN PLAN", - usedQuota: 250, - totalQuota: 1000, - remainingQuota: nil, - resetsAt: reset, + quota: .creditPool(used: 250, total: 1000, remaining: nil, resetsAt: reset), updatedAt: now) let usage = snapshot.toUsageSnapshot() @@ -193,10 +190,7 @@ struct AlibabaTokenPlanUsageSnapshotTests { func `does not create primary window from balance only`() { let snapshot = AlibabaTokenPlanUsageSnapshot( planName: "TOKEN PLAN", - usedQuota: nil, - totalQuota: nil, - remainingQuota: 700, - resetsAt: nil, + quota: .creditPool(used: nil, total: nil, remaining: 700, resetsAt: nil), updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) let usage = snapshot.toUsageSnapshot() diff --git a/TestsLinux/AlibabaTokenPlanPersonalLinuxTests.swift b/TestsLinux/AlibabaTokenPlanPersonalLinuxTests.swift new file mode 100644 index 0000000000..728aa2bafc --- /dev/null +++ b/TestsLinux/AlibabaTokenPlanPersonalLinuxTests.swift @@ -0,0 +1,449 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +/// Payload shapes captured from home.qwencloud.com's "Token Plan (Individual)" console. +private enum Fixtures { + static let usage = Data(""" + {"code":"200","data":{"DataV2":{"ret":["SUCCESS::OK"],"data":{"msg":"Success.","code":"SUCCESS", + "data":{"per5HourPercentage":0.010549299152142857,"per1WeekResetTime":1785066000000, + "per5HourResetTime":1784479200000,"per1WeekPercentage":0.0029538037626},"success":true}}, + "success":true,"httpStatus":200,"errorCode":"","errorMsg":""},"successResponse":true} + """.utf8) + + static let subscription = Data(""" + {"code":"200","data":{"DataV2":{"data":{"msg":"Success.","code":"SUCCESS", + "data":{"instanceCode":"sfm_tokenplansolo_public_intl-sg-x1","specCode":"lite","remainingDays":31, + "startTime":1784460830000,"endTime":1787155200000,"autoRenewFlag":false,"status":"VALID"}, + "success":true}},"success":true,"httpStatus":200,"errorMsg":""},"successResponse":true} + """.utf8) + + static let quotaConfig = Data(""" + {"code":"200","data":{"DataV2":{"data":{"msg":"Success.","code":"SUCCESS", + "data":{"standard":{"five_hour":3000.0,"weekly":10000.0},"lite":{"five_hour":700.0,"weekly":2500.0}, + "pro":{"five_hour":12000.0,"weekly":40000.0}},"success":true}}, + "success":true,"httpStatus":200,"errorMsg":""},"successResponse":true} + """.utf8) + + static let gatewayFailure = Data(""" + {"code":"200","data":{"success":false,"errorCode":"ConsoleNeedLogin","errorMsg":"Please log in."}, + "successResponse":false} + """.utf8) +} + +struct AlibabaTokenPlanPersonalAPITests { + @Test + func `parses rolling window percentages as fractions of one`() throws { + let usage = try AlibabaTokenPlanPersonalAPI.parseUsage(from: Fixtures.usage) + + #expect(abs(usage.fiveHourPercent - 1.0549299152142857) < 0.000_001) + #expect(abs(usage.weeklyPercent - 0.29538037626) < 0.000_001) + #expect(usage.fiveHourResetsAt == Date(timeIntervalSince1970: 1_784_479_200)) + #expect(usage.weeklyResetsAt == Date(timeIntervalSince1970: 1_785_066_000)) + } + + @Test + func `parses subscription tier and status`() throws { + let subscription = try AlibabaTokenPlanPersonalAPI.parseSubscription(from: Fixtures.subscription) + + #expect(subscription.specCode == "lite") + #expect(subscription.status == "VALID") + } + + @Test + func `plan name reflects tier and flags a lapsed subscription`() { + #expect(AlibabaTokenPlanPersonalAPI.planName(specCode: "lite", status: "VALID") == "Token Plan Lite") + #expect(AlibabaTokenPlanPersonalAPI.planName(specCode: "LITE", status: nil) == "Token Plan Lite") + #expect(AlibabaTokenPlanPersonalAPI.planName(specCode: nil, status: nil) == "Token Plan") + #expect( + AlibabaTokenPlanPersonalAPI.planName(specCode: "pro", status: "EXPIRED") == + "Token Plan Pro (EXPIRED)") + // Unknown tiers pass through verbatim rather than being mangled by capitalization. + #expect(AlibabaTokenPlanPersonalAPI.planName(specCode: "ultra_x1", status: nil) == "Token Plan ultra_x1") + } + + @Test + func `tier lookup is case insensitive`() throws { + let config = try AlibabaTokenPlanPersonalAPI.parseQuotaConfig(from: Fixtures.quotaConfig) + + #expect(AlibabaTokenPlanPersonalAPI.tier(for: "LITE", in: config)?.fiveHour == 700) + #expect(AlibabaTokenPlanPersonalAPI.tier(for: "nope", in: config) == nil) + #expect(AlibabaTokenPlanPersonalAPI.tier(for: nil, in: config) == nil) + } + + @Test + func `quota config throws rather than silently returning nothing`() { + let renamed = Data(""" + {"code":"200","data":{"DataV2":{"data":{"code":"SUCCESS", + "data":{"lite":{"fiveHour":700.0,"weekly":2500.0}},"success":true}}, + "success":true,"errorMsg":""},"successResponse":true} + """.utf8) + + #expect(throws: AlibabaTokenPlanUsageError.self) { + try AlibabaTokenPlanPersonalAPI.parseQuotaConfig(from: renamed) + } + } + + @Test + func `parses credit ceilings for every tier`() throws { + let config = try AlibabaTokenPlanPersonalAPI.parseQuotaConfig(from: Fixtures.quotaConfig) + + #expect(config["lite"]?.fiveHour == 700) + #expect(config["lite"]?.weekly == 2500) + #expect(config["pro"]?.fiveHour == 12000) + #expect(config.count == 3) + } + + /// Stale sessions must classify as `.loginRequired`, otherwise the descriptor never treats + /// them as a credential failure and never clears/re-imports the cookie cache. + @Test + func `classifies stale sessions as login required`() { + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanPersonalAPI.parseUsage(from: Fixtures.gatewayFailure) + } + } + + @Test + func `classifies a login html body as login required`() { + let html = Data("
Sign in
".utf8) + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanPersonalAPI.parseUsage(from: html) + } + } + + @Test + func `reports genuine api errors with their message`() { + let failure = Data(""" + {"code":"500","data":{"success":false,"errorCode":"InternalError","errorMsg":"Backend exploded."}, + "successResponse":false} + """.utf8) + + #expect(throws: AlibabaTokenPlanUsageError.apiError("Backend exploded.")) { + try AlibabaTokenPlanPersonalAPI.parseUsage(from: failure) + } + } + + @Test + func `surfaces root level failures instead of masking them as parse errors`() { + let rootFailure = Data(""" + {"code":"403","message":"Forbidden by policy.","successResponse":false,"data":null} + """.utf8) + + #expect(throws: AlibabaTokenPlanUsageError.apiError("Forbidden by policy.")) { + try AlibabaTokenPlanPersonalAPI.parseUsage(from: rootFailure) + } + } + + /// The REST-layer envelope is where a stale session most often surfaces. + @Test + func `classifies an inner envelope login failure as login required`() { + let innerFailure = Data(""" + {"code":"200","data":{"DataV2":{"data":{"code":"NeedLogin","msg":"Session expired.", + "success":false}},"success":true,"errorMsg":""},"successResponse":true} + """.utf8) + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanPersonalAPI.parseUsage(from: innerFailure) + } + } + + @Test + func `reports an inner envelope api error with its message`() { + let innerFailure = Data(""" + {"code":"200","data":{"DataV2":{"data":{"code":"Throttled","msg":"Too many requests.", + "success":false}},"success":true,"errorMsg":""},"successResponse":true} + """.utf8) + + #expect(throws: AlibabaTokenPlanUsageError.apiError("Too many requests.")) { + try AlibabaTokenPlanPersonalAPI.parseUsage(from: innerFailure) + } + } + + /// A gateway can drop the envelope entirely and return a bare auth error with no success flag; + /// that must still reach the cookie re-import path rather than degrading to a parse error. + @Test + func `classifies an envelope-less auth error as login required`() { + let bare = Data(""" + {"code":"401","data":{"errorCode":"ConsoleNeedLogin","errorMsg":"Please log in."}} + """.utf8) + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanPersonalAPI.parseUsage(from: bare) + } + } + + @Test + func `a genuinely malformed body is still a parse failure`() { + let malformed = Data(""" + {"code":"200","data":{"unexpected":"shape"},"successResponse":true} + """.utf8) + + #expect(throws: AlibabaTokenPlanUsageError.parseFailed("Missing DataV2 payload")) { + try AlibabaTokenPlanPersonalAPI.parseUsage(from: malformed) + } + } + + @Test + func `missing usage percentages are a parse failure`() { + let empty = Data(""" + {"code":"200","data":{"DataV2":{"data":{"code":"SUCCESS","data":{},"success":true}}, + "success":true,"errorMsg":""},"successResponse":true} + """.utf8) + + #expect(throws: AlibabaTokenPlanUsageError.self) { + try AlibabaTokenPlanPersonalAPI.parseUsage(from: empty) + } + } + + @Test + func `form body escapes plus so it is not decoded as a space`() throws { + let body = AlibabaTokenPlanUsageFetcher.formEncodedBody([URLQueryItem(name: "sec_token", value: "ab+c")]) + let text = try #require(String(data: body, encoding: .utf8)) + + #expect(text == "sec_token=ab%2Bc") + } + + @Test + func `builds the console gateway url per endpoint`() throws { + let url = try #require(AlibabaTokenPlanPersonalAPI.requestURL( + baseURLString: AlibabaTokenPlanAPIRegion.qwenCloudPersonal.quotaAPIBaseURLString, + endpoint: .usage)) + + #expect(url.host == "cs-data.qwencloud.com") + #expect(url.path == "/data/api.json") + let query = try #require(url.query) + #expect(query.contains("product=sfm_bailian")) + #expect(query.contains("action=IntlBroadScopeAspnGateway")) + #expect(url.absoluteString.contains("tokenplan/personal/api/v2/usage")) + } + + @Test + func `request body carries sec token region and tunneled api path`() throws { + let body = AlibabaTokenPlanPersonalAPI.requestBody( + endpoint: .quotaConfig, + region: .qwenCloudPersonal, + secToken: "abc123") + let text = try #require(String(data: body, encoding: .utf8)) + + #expect(text.contains("sec_token=abc123")) + #expect(text.contains("region=ap-southeast-1")) + #expect(text.contains("QWENCLOUD")) + #expect(text.contains("quota-config")) + } + + @Test + func `omits sec token when unavailable`() throws { + let body = AlibabaTokenPlanPersonalAPI.requestBody( + endpoint: .usage, + region: .qwenCloudPersonal, + secToken: nil) + let text = try #require(String(data: body, encoding: .utf8)) + + #expect(!text.contains("sec_token")) + } +} + +struct AlibabaTokenPlanPersonalSnapshotTests { + @Test + func `maps rolling windows to primary and secondary`() { + let now = Date(timeIntervalSince1970: 1_784_461_000) + let reset5h = Date(timeIntervalSince1970: 1_784_479_200) + let resetWeek = Date(timeIntervalSince1970: 1_785_066_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "Token Plan Lite", + quota: .rollingWindows( + fiveHour: AlibabaTokenPlanRollingWindow( + usedPercent: 10, + totalCredits: 700, + resetsAt: reset5h), + weekly: AlibabaTokenPlanRollingWindow( + usedPercent: 20, + totalCredits: 2500, + resetsAt: resetWeek)), + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 10) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == reset5h) + #expect(usage.primary?.resetDescription == "70 / 700 credits used") + #expect(usage.secondary?.usedPercent == 20) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetsAt == resetWeek) + #expect(usage.secondary?.resetDescription == "500 / 2,500 credits used") + #expect(usage.loginMethod(for: .alibabatokenplan) == "Token Plan Lite") + } + + @Test + func `omits credit detail when the tier ceiling is unknown`() { + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + quota: .rollingWindows( + fiveHour: AlibabaTokenPlanRollingWindow(usedPercent: 5, totalCredits: nil, resetsAt: nil), + weekly: AlibabaTokenPlanRollingWindow(usedPercent: 7, totalCredits: nil, resetsAt: nil)), + updatedAt: Date(timeIntervalSince1970: 1_784_461_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 5) + #expect(usage.primary?.resetDescription == nil) + #expect(usage.secondary?.usedPercent == 7) + } + + @Test + func `credit pool still renders a single monthly window`() { + let reset = Date(timeIntervalSince1970: 1_700_100_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + quota: .creditPool(used: 250, total: 1000, remaining: nil, resetsAt: reset), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.windowMinutes == 43200) + #expect(usage.secondary == nil) + } +} + +struct AlibabaTokenPlanCookieScopingTests { + private static func cookie(name: String, domain: String, path: String = "/") -> HTTPCookie { + HTTPCookie(properties: [ + .name: name, + .value: "v-\(name)", + .domain: domain, + .path: path, + ])! + } + + /// Qwen's console and data gateway are sibling hosts, so a host-scoped console cookie must + /// still reach the gateway request. + @Test + func `personal region unions console and gateway cookies`() throws { + let cookies = [ + Self.cookie(name: "login_qwencloud_ticket", domain: "home.qwencloud.com"), + Self.cookie(name: "cna", domain: ".qwencloud.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + region: .qwenCloudPersonal, + environment: [:])) + + #expect(headers.apiCookieHeader.contains("login_qwencloud_ticket")) + #expect(headers.apiCookieHeader.contains("cna")) + } + + /// Regression guard: for intl/cn both hosts are identical, so the union must be a strict + /// no-op and dashboard path-scoped cookies must not leak into the api header. + @Test + func `team regions keep api and dashboard cookies separately scoped`() throws { + let cookies = [ + Self.cookie(name: "shared", domain: "bailian.console.aliyun.com"), + Self.cookie(name: "dashboard_only", domain: "bailian.console.aliyun.com", path: "/cn-beijing"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + region: .chinaMainland, + environment: [:])) + + #expect(headers.apiCookieHeader.contains("shared")) + #expect(!headers.apiCookieHeader.contains("dashboard_only")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only")) + } +} + +struct AlibabaTokenPlanLinuxGatingTests { + @Test + func `manual cookies let the token plan run without macOS web support`() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .alibabatokenplan, + settings: ProviderSettingsSnapshot.make( + alibabaTokenPlan: .init( + cookieSource: .manual, + manualCookieHeader: "login_qwencloud_ticket=test", + apiRegion: .qwenCloudPersonal)))) + } + + @Test + func `browser cookie import still requires macOS web support`() { + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .alibabatokenplan, + environment: [:], + settings: ProviderSettingsSnapshot.make( + alibabaTokenPlan: .init(cookieSource: .auto, manualCookieHeader: nil)))) + } + + /// `ALIBABA_TOKEN_PLAN_COOKIE` is honored even when the configured source is `auto`, so it has + /// to open the gate by itself — otherwise that documented setup stays unusable on Linux. + @Test + func `an environment cookie alone opens the gate`() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .alibabatokenplan, + environment: [AlibabaTokenPlanSettingsReader.cookieHeaderKey: "login_qwencloud_ticket=test"], + settings: ProviderSettingsSnapshot.make( + alibabaTokenPlan: .init(cookieSource: .auto, manualCookieHeader: nil)))) + } + + @Test + func `an empty environment cookie does not open the gate`() { + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .alibabatokenplan, + environment: [AlibabaTokenPlanSettingsReader.cookieHeaderKey: " "], + settings: ProviderSettingsSnapshot.make( + alibabaTokenPlan: .init(cookieSource: .auto, manualCookieHeader: nil)))) + } + + @Test + func `personal region targets the qwen cloud console`() { + let region = AlibabaTokenPlanAPIRegion.qwenCloudPersonal + #expect(region.rawValue == "qwen") + #expect(region.gatewayBaseURLString == "https://home.qwencloud.com") + #expect(region.quotaAPIBaseURLString == "https://cs-data.qwencloud.com") + #expect(region.currentRegionID == "ap-southeast-1") + #expect(region.tokenPlanProductCode == nil) + #expect(region.usesPersonalTokenPlanAPI) + } + + @Test + func `personal region is manual-cookie only`() { + #expect(!AlibabaTokenPlanAPIRegion.qwenCloudPersonal.supportsBrowserCookieImport) + #expect(AlibabaTokenPlanAPIRegion.international.supportsBrowserCookieImport) + #expect(AlibabaTokenPlanAPIRegion.chinaMainland.supportsBrowserCookieImport) + } + + @Test + func `personal gateway url honours a host override`() { + let overridden = AlibabaTokenPlanUsageFetcher.resolveQuotaURL( + region: .qwenCloudPersonal, + environment: [AlibabaTokenPlanSettingsReader.hostKey: "cs-data.qwencloud.com"]) + #expect(overridden.host == "cs-data.qwencloud.com") + + let defaulted = AlibabaTokenPlanUsageFetcher.resolveQuotaURL( + region: .qwenCloudPersonal, + environment: [:]) + #expect(defaulted.host == "cs-data.qwencloud.com") + #expect(defaulted.absoluteString.contains("tokenplan/personal/api/v2/usage")) + } + + @Test + func `team regions keep their commodity codes and console hosts`() { + #expect(AlibabaTokenPlanAPIRegion.international.tokenPlanProductCode == "sfm_tokenplanteams_dp_intl") + #expect(AlibabaTokenPlanAPIRegion.chinaMainland.tokenPlanProductCode == "sfm_tokenplanteams_dp_cn") + #expect(!AlibabaTokenPlanAPIRegion.international.usesPersonalTokenPlanAPI) + #expect( + AlibabaTokenPlanAPIRegion.international.quotaAPIBaseURLString == + "https://modelstudio.console.alibabacloud.com") + } +} diff --git a/docs/alibaba-token-plan.md b/docs/alibaba-token-plan.md index 1d8884605e..24cec3029e 100644 --- a/docs/alibaba-token-plan.md +++ b/docs/alibaba-token-plan.md @@ -15,6 +15,17 @@ The Alibaba Token Plan provider tracks Bailian token-plan credits from the Aliba - **Token-plan usage display**: Shows used, total, and remaining token-plan credits when Bailian returns 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. +- **Personal plan windows**: The Qwen Cloud personal plan reports rolling 5-hour and weekly windows instead of a credit pool. + +## Editions + +Two different products ship under the "token plan" name, and they do not share an API: + +| Region | Console | Quota API | +| --- | --- | --- | +| `intl` | `modelstudio.console.alibabacloud.com` | `GetSubscriptionSummary` (team credit pool) | +| `cn` | `bailian.console.aliyun.com` | `GetSubscriptionSummary` (team credit pool) | +| `qwen` | `home.qwencloud.com` | `zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/*` (rolling windows) | ## Setup @@ -36,11 +47,34 @@ The Alibaba Token Plan provider tracks Bailian token-plan credits from the Aliba - Parses `TotalValue`, `TotalSurplusValue`, `TotalCount`, and `NearestExpireDate` from the subscription summary response - Supports `ALIBABA_TOKEN_PLAN_HOST` and `ALIBABA_TOKEN_PLAN_QUOTA_URL` for testing endpoint overrides +## Qwen Cloud personal plan (`region: "qwen"`) + +Posts to `https://cs-data.qwencloud.com/data/api.json?product=sfm_bailian&action=IntlBroadScopeAspnGateway` +with the REST path tunneled through the `params` payload. Three endpoints are used: + +- `/tokenplan/personal/api/v2/usage` — `per5HourPercentage`, `per1WeekPercentage` (fractions of + 1.0) plus `per5HourResetTime` / `per1WeekResetTime` (epoch ms). Required. +- `/tokenplan/personal/api/v2/subscription` — `specCode` (`lite` / `standard` / `pro`), `status`, + `remainingDays`, `endTime`. Best-effort. +- `/tokenplan/personal/api/v2/quota-config` — per-tier `five_hour` / `weekly` credit ceilings, joined + to `specCode` to turn the percentages into credit counts. Best-effort. + +Responses nest the payload at `data.DataV2.data.data`. `sec_token` resolves from +`GET https://home.qwencloud.com/tool/user/info.json` (`data.secToken`), the same path the team +edition uses, so cookies alone are sufficient. + ## 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 +- Alibaba Token Plan supports the web-cookie path only; API keys are inference-only credentials and + return `ConsoleNeedLogin` against the console gateway +- Token cost summaries and automatic status polling are not supported - The default endpoint is the China mainland Bailian token-plan subscription summary +- Browser cookie auto-import remains macOS-only; on Linux set the cookie source to manual +- `region: "qwen"` is **manual-cookie only on every platform**. Qwen Cloud logs in under its own + domain with its own ticket cookie (`login_qwencloud_ticket`), which the shared Alibaba browser + importer does not recognise, so auto-import is rejected up front with a message pointing at the + console. Paste a `Cookie:` header from + `https://home.qwencloud.com/billing/subscription/token-plan-individual`. ## Troubleshooting