From 73ebb460e5f4fc47c99b5c801f47385f39f6c098 Mon Sep 17 00:00:00 2001 From: yacha Date: Sun, 19 Jul 2026 14:03:04 +0200 Subject: [PATCH 1/2] Support Qwen Cloud personal token plan and unblock Linux The Qwen Cloud personal ("solo") token plan is a different product from the team edition already supported: it lives on home.qwencloud.com, posts to a separate gateway host, tunnels a REST path through the generic ASPN action, and reports rolling 5-hour/weekly windows instead of a monthly credit pool. Add a qwen region covering that gateway, and model the two quota shapes as an enum so the invalid mixes of the previous optional fields are unrepresentable. Also exempt the provider from the macOS web-support gate when cookies are supplied manually, matching qoder/opencodego/commandcode. The quota fetch is plain URLSession plus cookies; only browser cookie import needs macOS, so the provider was unreachable on Linux for no reason. --- CHANGELOG.md | 5 + Sources/CodexBarCLI/CLIUsageCommand.swift | 6 + .../Alibaba/AlibabaTokenPlanAPIRegion.swift | 39 +- .../AlibabaTokenPlanCookieHeader.swift | 21 +- .../Alibaba/AlibabaTokenPlanPersonalAPI.swift | 227 ++++++++++ .../AlibabaTokenPlanProviderDescriptor.swift | 7 + .../AlibabaTokenPlanSettingsReader.swift | 2 + .../AlibabaTokenPlanUsageFetcher.swift | 182 ++++++-- .../AlibabaTokenPlanUsageSnapshot.swift | 95 ++-- .../AlibabaTokenPlanMenuCardModelTests.swift | 9 +- .../AlibabaTokenPlanProviderTests.swift | 10 +- .../AlibabaTokenPlanPersonalLinuxTests.swift | 426 ++++++++++++++++++ docs/alibaba-token-plan.md | 38 +- 13 files changed, 979 insertions(+), 88 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalAPI.swift create mode 100644 TestsLinux/AlibabaTokenPlanPersonalLinuxTests.swift 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..5071580340 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -713,6 +713,12 @@ extension CodexBarCLI { { return false } + if provider == .alibabatokenplan, + settings?.alibabaTokenPlan?.cookieSource == .manual + { + // The quota fetch is plain URLSession + cookies; only browser import needs macOS. + 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..7cc8c44b3f --- /dev/null +++ b/TestsLinux/AlibabaTokenPlanPersonalLinuxTests.swift @@ -0,0 +1,426 @@ +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, + 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 From b1efecbdb08f142c9e1513e5a999e225ce44f17d Mon Sep 17 00:00:00 2001 From: yacha Date: Sun, 19 Jul 2026 14:59:24 +0200 Subject: [PATCH 2/2] Open the Linux gate for ALIBABA_TOKEN_PLAN_COOKIE too The env cookie is honored regardless of the configured cookie source, so a user setting only that variable was still blocked before the strategy could read it. Mirrors the existing Sakana env-cookie check. --- Sources/CodexBarCLI/CLIUsageCommand.swift | 5 +++- .../AlibabaTokenPlanPersonalLinuxTests.swift | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 5071580340..ed95cdda1b 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -714,9 +714,12 @@ extension CodexBarCLI { return false } if provider == .alibabatokenplan, - settings?.alibabaTokenPlan?.cookieSource == .manual + 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, diff --git a/TestsLinux/AlibabaTokenPlanPersonalLinuxTests.swift b/TestsLinux/AlibabaTokenPlanPersonalLinuxTests.swift index 7cc8c44b3f..728aa2bafc 100644 --- a/TestsLinux/AlibabaTokenPlanPersonalLinuxTests.swift +++ b/TestsLinux/AlibabaTokenPlanPersonalLinuxTests.swift @@ -378,6 +378,29 @@ struct AlibabaTokenPlanLinuxGatingTests { #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)))) }