Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBarCLI/CLIUsageCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import Foundation
public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable {
case international = "intl"
case chinaMainland = "cn"
case qwenCloudPersonal = "qwen"

public var displayName: String {
switch self {
case .international:
"International (modelstudio.console.alibabacloud.com)"
case .chinaMainland:
"China mainland (bailian.console.aliyun.com)"
case .qwenCloudPersonal:
"Qwen Cloud personal (home.qwencloud.com)"
}
}

Expand All @@ -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"
}
}

Expand All @@ -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)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,25 +83,32 @@ 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
}
return AlibabaTokenPlanCookieHeaders(apiCookieHeader: apiHeader, dashboardCookieHeader: dashboardHeader)
}

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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading