-
Notifications
You must be signed in to change notification settings - Fork 826
Add Alibaba Cloud Model Studio provider #559
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
81ab581
5679122
bf63255
7bc59aa
0092373
0ee0252
7e1771a
da038a1
c6cf30e
09d1725
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ) | ||
| } | ||
| } |
| 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) | ||
| 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" | ||
| } | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Comment on lines
+30
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fetcher and snapshot model collect Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @codex review |
||
| identity: nil | ||
| ) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This decode step treats an object full of
nullvalues as a successful parse because every field inAlibabaConsoleResponseis optional, andparseUsageResponsethen defaults those missing values to0%/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 👍 / 👎.