Skip to content
4 changes: 4 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-alibaba.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
132 changes: 132 additions & 0 deletions Sources/CodexBarCore/Providers/Alibaba/AlibabaProviderDescriptor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import CodexBarMacroSupport
import Foundation

@ProviderDescriptorRegistration
@ProviderDescriptorDefinition
public enum AlibabaProviderDescriptor {
static func makeDescriptor() -> ProviderDescriptor {
ProviderDescriptor(
id: .alibaba,
metadata: ProviderMetadata(
id: .alibaba,
displayName: "Alibaba Model Studio",
sessionLabel: "5 hours",
weeklyLabel: "Weekly",
opusLabel: nil,
supportsOpus: false,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show Alibaba usage",
cliName: "alibaba",
defaultEnabled: false,
isPrimaryProvider: false,
usesAccountFallback: false,
browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder,
dashboardURL: "https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan",
statusPageURL: nil),
branding: ProviderBranding(
iconStyle: .alibaba,
iconResourceName: "ProviderIcon-alibaba",
color: ProviderColor(red: 1.0, green: 0.4, blue: 0.2)),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Alibaba cost summary is not supported." }),
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .web],
pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),
cli: ProviderCLIConfig(
name: "alibaba",
aliases: ["dashscope", "coding-plan"],
versionDetector: nil))
}

private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] {
switch context.sourceMode {
case .web:
return [AlibabaWebFetchStrategy()]
case .auto:
break
case .api, .cli, .oauth:
return []
}
// Default to web scraping (requires browser cookies)
return [AlibabaWebFetchStrategy()]
}
}

struct AlibabaWebFetchStrategy: ProviderFetchStrategy {
let id: String = "alibaba.web"
let kind: ProviderFetchKind = .webDashboard

func isAvailable(_ context: ProviderFetchContext) async -> Bool {
// Web strategy is available when source mode allows web
return context.sourceMode.usesWeb
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
// Ensure AppKit is initialized before using WebKit in a CLI.
await MainActor.run {
_ = NSApplication.shared
}

// Use fetcher's browser detection for cookie access
let browserDetection = context.browserDetection

// Check if user has browser cookies for alibabacloud.com
// This uses the browserDetection to check cookie availability
let hasCookies = await browserDetection.hasCookies(for: "alibabacloud.com")
guard hasCookies else {
throw AlibabaUsageError.missingCookies
}

// Fetch usage via WebView scraping
let options = AlibabaWebOptions(
timeout: context.webTimeout,
debugDumpHTML: context.webDebugDumpHTML,
verbose: context.verbose
)

let usage = try await Self.fetchAlibabaWeb(
browserDetection: browserDetection,
options: options
)

return self.makeResult(
usage: usage,
credits: nil,
dashboard: nil,
sourceLabel: "alibaba-web"
)
}

func shouldFallback(on error: Error, context: ProviderFetchContext) async -> Bool {
guard context.sourceMode == .auto else { return false }
// Fallback on cookie errors or page load failures
if case AlibabaUsageError.missingCookies = error { return true }
if case AlibabaUsageError.pageLoadFailed = error { return true }
return false
}
}

private struct AlibabaWebOptions {
let timeout: TimeInterval
let debugDumpHTML: Bool
let verbose: Bool
}

@MainActor
extension AlibabaWebFetchStrategy {
fileprivate static func fetchAlibabaWeb(
browserDetection: BrowserDetection,
options: AlibabaWebOptions
) async throws -> UsageSnapshot {
// Use browser detection to access cookies and create WebView
// This follows the same pattern as CodexWebDashboardStrategy
try await AlibabaUsageFetcher.fetchUsage(
browserDetection: browserDetection,
timeout: options.timeout,
debugDumpHTML: options.debugDumpHTML,
verbose: options.verbose
)
}
}
221 changes: 221 additions & 0 deletions Sources/CodexBarCore/Providers/Alibaba/AlibabaUsageFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import Foundation

enum AlibabaUsageError: Error, CustomStringConvertible {
case missingCookies
case pageLoadFailed
case parseFailed(String)
case apiError(String)

var description: String {
switch self {
case .missingCookies:
return "Missing Alibaba Cloud cookies. Please log in to alibabacloud.com"
case .pageLoadFailed:
return "Failed to load Alibaba Cloud console"
case .parseFailed(let detail):
return "Failed to parse usage data: \(detail)"
case .apiError(let detail):
return "API error: \(detail)"
}
}
}

enum AlibabaUsageFetcher {
/// Fetch usage data from Alibaba Cloud console via WebView scraping
@MainActor
static func fetchUsage(
browserDetection: BrowserDetection,
timeout: TimeInterval,
debugDumpHTML: Bool,
verbose: Bool
) async throws -> UsageSnapshot {
// Check if user has cookies for alibabacloud.com
guard await browserDetection.hasCookies(for: "alibabacloud.com") else {
throw AlibabaUsageError.missingCookies
}

// Create WebView using browser detection's profile
// This follows the pattern from CodexWebDashboardStrategy
let webView = browserDetection.createWebView()
defer { webView.close() }

let consoleURL = "https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan"

// Navigate to console with cookies using configured timeout
try await webView.load(url: consoleURL, timeout: timeout)

// Wait for usage selectors to appear instead of fixed sleep
// Usage cards are in [ref="e168"], [ref="e187"], [ref="e206"]
// Poll for up to 10 seconds with 500ms intervals
let usageSelectors = ["e168", "e187", "e206"]
var allSelectorsReady = false

for attempt in 1...20 { // 20 attempts × 500ms = 10 seconds max
var readyCount = 0
for selector in usageSelectors {
// Check if element exists by returning a primitive (not bridging DOM object)
let script = "document.querySelector('[ref=\"\(selector)\"]') !== null"
if let exists = try await webView.evaluateJavaScript(script) as? Bool, exists {
readyCount += 1
}
}
if readyCount == usageSelectors.count {
allSelectorsReady = true
break
}
if attempt < 20 {
try await Task.sleep(nanoseconds: 500_000_000) // 500ms
}
}

guard allSelectorsReady else {
throw AlibabaUsageError.pageLoadFailed
}

// Extract usage data via JavaScript
let script = """
(function() {
function getText(ref) {
const el = document.querySelector('[ref="' + ref + '"]');
return el ? el.innerText.trim() : null;
}

return {
plan: getText('e98'),
status: getText('e111'),
remainingDays: getText('e116'),
startTime: getText('e121'),
endTime: getText('e126'),
usage5h: getText('e168'),
usage5hReset: getText('e167'),
usage7d: getText('e187'),
usage7dReset: getText('e186'),
usage30d: getText('e206'),
usage30dReset: getText('e205')
};
})()
"""

guard let result = try await webView.evaluateJavaScript(script) as? [String: Any?],
let jsonData = try? JSONSerialization.data(withJSONObject: result),
let response = try? JSONDecoder().decode(AlibabaConsoleResponse.self, from: jsonData)
Comment on lines +99 to +101
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fail when DOM selectors return only null usage fields

This decode step treats an object full of null values as a successful parse because every field in AlibabaConsoleResponse is optional, and parseUsageResponse then defaults those missing values to 0%/synthetic reset dates. When selectors drift or the page hasn’t rendered yet, users will see a seemingly valid low-usage snapshot instead of an error, which can hide scraping breakage and mislead quota decisions. Validate that at least one required usage field is present before returning a snapshot.

Useful? React with 👍 / 👎.

else {
throw AlibabaUsageError.parseFailed("Could not extract usage data from DOM")
}

// Validate that ALL required usage fields are present
// This prevents partial selector drift from producing misleading quota signals
// If any window selector breaks, we should fail fast rather than show 0.0%
guard response.usage5h != nil && response.usage7d != nil && response.usage30d != nil else {
let missingFields: [String] = {
var missing: [String] = []
if response.usage5h == nil { missing.append("5h") }
if response.usage7d == nil { missing.append("7d") }
if response.usage30d == nil { missing.append("30d") }
return missing
}()
throw AlibabaUsageError.parseFailed("Missing usage fields: \(missingFields.joined(separator: ", ")). Selectors may have drifted or page not rendered")
}

// Parse the response into usage snapshot
return parseUsageResponse(response)
}

private static func parseUsageResponse(_ response: AlibabaConsoleResponse) throws -> AlibabaUsageSnapshot {
// Parse percentage strings like "9%", "46%", "63%"
// CRITICAL: Do NOT default to 0.0 - throw error if parsing fails
// This prevents silently showing 0% when selectors drift or page format changes
guard let usage5hPercent = parsePercentage(response.usage5h) else {
throw AlibabaUsageError.parseFailed("Failed to parse 5h usage: '\(response.usage5h ?? "nil")'")
}
guard let usage7dPercent = parsePercentage(response.usage7d) else {
throw AlibabaUsageError.parseFailed("Failed to parse 7d usage: '\(response.usage7d ?? "nil")'")
}
guard let usage30dPercent = parsePercentage(response.usage30d) else {
throw AlibabaUsageError.parseFailed("Failed to parse 30d usage: '\(response.usage30d ?? "nil")'")
}

// Calculate reset times
let reset5h = parseResetTime(response.usage5hReset) ?? Date().addingTimeInterval(5 * 60 * 60)
let reset7d = parseResetTime(response.usage7dReset) ?? Date().addingTimeInterval(7 * 24 * 60 * 60)
let reset30d = parseResetTime(response.usage30dReset) ?? Date().addingTimeInterval(30 * 24 * 60 * 60)

return AlibabaUsageSnapshot(
plan: response.plan ?? "Unknown",
status: response.status ?? "Unknown",
remainingDays: response.remainingDays ?? "",
sessionUsage: RateWindow(
usedPercent: usage5hPercent,
windowMinutes: 300, // 5 hours
resetsAt: reset5h,
resetDescription: "Resets in 5 hours"
),
weeklyUsage: RateWindow(
usedPercent: usage7dPercent,
windowMinutes: 10080, // 7 days
resetsAt: reset7d,
resetDescription: "Resets weekly"
),
monthlyUsage: RateWindow(
usedPercent: usage30dPercent,
windowMinutes: 43200, // 30 days
resetsAt: reset30d,
resetDescription: "Resets monthly"
),
updatedAt: Date()
)
}

private static func parsePercentage(_ string: String?) -> Double? {
guard let string = string, !string.isEmpty else { return nil }
let cleaned = string.replacingOccurrences(of: "%", with: "").trimmingCharacters(in: .whitespaces)
guard let percent = Double(cleaned) else {
// String exists but is not a valid number - this indicates selector drift or malformed data
return nil
}
return percent
}

private static func parseResetTime(_ string: String?) -> Date? {
guard let string = string else { return nil }
// Expected format: "2026-03-17 06:57:57 Reset" or "2026-03-17 06:57:57" (possibly with localized suffix)
// Extract just the timestamp portion (first 19 chars: "yyyy-MM-dd HH:mm:ss")
// This avoids assuming English "Reset" suffix or any specific localized text
let timestampLength = 19 // "yyyy-MM-dd HH:mm:ss"
guard string.count >= timestampLength else { return nil }
let dateString = String(string.prefix(timestampLength)).trimmingCharacters(in: .whitespaces)

let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
// Don't set explicit timezone - use system timezone to match displayed console time
// Users in Singapore will see SGT times, users in PY will see PYT times, etc.
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"

return dateFormatter.date(from: dateString)
}
}

/// Response from JavaScript extraction
struct AlibabaConsoleResponse: Codable {
let plan: String?
let status: String?
let remainingDays: String?
let startTime: String?
let endTime: String?
let usage5h: String?
let usage5hReset: String?
let usage7d: String?
let usage7dReset: String?
let usage30d: String?
let usage30dReset: String?

enum CodingKeys: String, CodingKey {
case plan, status, remainingDays, startTime, endTime
case usage5h = "usage5h"
case usage5hReset = "usage5hReset"
case usage7d = "usage7d"
case usage7dReset = "usage7dReset"
case usage30d = "usage30d"
case usage30dReset = "usage30dReset"
}
}
38 changes: 38 additions & 0 deletions Sources/CodexBarCore/Providers/Alibaba/AlibabaUsageSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

/// Usage snapshot for Alibaba Cloud Model Studio Coding Plan
struct AlibabaUsageSnapshot {
/// Plan name (e.g., "Lite Basic Plan", "Pro")
let plan: String

/// Plan status (e.g., "Taking Effect", "Expired")
let status: String

/// Remaining days text (e.g., "17days")
let remainingDays: String

/// Usage in the last 5 hours (session window)
let sessionUsage: RateWindow

/// Usage in the last 7 days (weekly window)
let weeklyUsage: RateWindow

/// Usage in the last 30 days (monthly window)
let monthlyUsage: RateWindow

/// When this snapshot was taken
let updatedAt: Date

/// Convert to CodexBar's standard UsageSnapshot format
/// Note: UsageSnapshot supports primary (5h), secondary (7d), and tertiary (30d) windows.
/// All three windows are forwarded to ensure downstream UI receives complete quota data.
func toUsageSnapshot() -> UsageSnapshot {
UsageSnapshot(
primary: sessionUsage,
secondary: weeklyUsage,
tertiary: monthlyUsage,
updatedAt: updatedAt,
Comment on lines +30 to +34
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 monthly window when building usage snapshot

AlibabaUsageSnapshot captures monthlyUsage, but toUsageSnapshot() only forwards primary and secondary. This drops the 30-day metric before it reaches the shared UsageSnapshot model, so Alibaba users cannot see monthly usage despite the fetcher collecting it.

Useful? React with 👍 / 👎.

Comment on lines +30 to +34
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 Forward the collected 30-day usage window

The fetcher and snapshot model collect monthlyUsage, but this conversion drops it and only emits primary/secondary windows, so downstream UI/state never receives the 30-day data. As written, the provider advertises 30d support in code comments/features but discards that window before returning the shared snapshot.

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.

@codex review

identity: nil
)
}
}
Loading