Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions Sources/CodexBarCLI/CLIUsageCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,12 @@ extension CodexBarCLI {
{
return false
}
if provider == .alibabatokenplan,
settings?.alibabaTokenPlan?.cookieSource == .manual

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include ALIBABA_TOKEN_PLAN_COOKIE in Linux bypass

This bypass only covers config snapshots where cookieSource == .manual, but the provider also supports a manually supplied cookie via ALIBABA_TOKEN_PLAN_COOKIE in AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders. On Linux, a user who sets that existing env var while leaving the config at the default auto mode is still stopped by this gate before the strategy can read the cookie, so the provider remains unusable in that manual-cookie setup; add the same env-cookie check here that other providers such as Sakana use.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in b1efecb.

You are right that resolveCookieHeaders reads ALIBABA_TOKEN_PLAN_COOKIE independently of cookieSource, so gating only on cookieSource == .manual blocked a setup that would otherwise have worked. The gate now also opens on the env cookie, mirroring the Sakana check:

if provider == .alibabatokenplan,
   settings?.alibabaTokenPlan?.cookieSource == .manual ||
   environment.map({ AlibabaTokenPlanSettingsReader.cookieHeader(environment: $0) != nil }) == true
{
    return false
}

Since cookieHeader(environment:) goes through cleaned(...), a blank or whitespace-only value still leaves the gate closed rather than letting the provider through to fail later.

Verified on Linux with the config left at the default auto mode:

  • no env var → selected source requires web support and is only supported on macOS (unchanged)
  • ALIBABA_TOKEN_PLAN_COOKIE=... → real usage returned

Added three cases for it (env-only opens the gate, whitespace-only does not, browser-import still gated). Suite is at 311 passing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

{
// The quota fetch is plain URLSession + cookies; only browser import needs macOS.
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