diff --git a/README.md b/README.md index 19bb92a763..263c04f21a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE) [![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app) -CodexBar — every AI coding limit in your menu bar. 63 providers. +CodexBar — every AI coding limit in your menu bar. 64 providers. Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, ClinePass, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons. @@ -85,6 +85,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [OpenCode Go](docs/opencode.md) — Browser or local SQLite data for Go usage windows. - [Alibaba Coding Plan](docs/alibaba-coding-plan.md) — Web cookies or API key for coding-plan quotas. - [Alibaba Token Plan](docs/alibaba-token-plan.md) — Bailian browser/manual cookies for token-plan credits. +- [Qwen Cloud](docs/qwen-cloud.md) — 5-hour and weekly individual Token Plan usage via browser/manual cookies. - [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies). - [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth. - [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing. diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index f1503ea771..8f20103e6c 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -2,6 +2,17 @@ import AppKit import CodexBarCore import SwiftUI +@MainActor +enum ProviderSettingsRefreshInteraction { + static func perform(operation: () async -> Void) async { + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await operation() + } + } + } +} + @MainActor struct ProvidersPane: View { let provider: UsageProvider @@ -139,7 +150,7 @@ struct ProvidersPane: View { private func triggerRefresh(for provider: UsageProvider) { Task { @MainActor in - await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ProviderSettingsRefreshInteraction.perform { if provider == .codex { await self.store.refreshCodexAccountScopedState(allowDisabled: true) } else { diff --git a/Sources/CodexBar/Providers/QwenCloud/QwenCloudProviderImplementation.swift b/Sources/CodexBar/Providers/QwenCloud/QwenCloudProviderImplementation.swift new file mode 100644 index 0000000000..213eb6bf3c --- /dev/null +++ b/Sources/CodexBar/Providers/QwenCloud/QwenCloudProviderImplementation.swift @@ -0,0 +1,91 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct QwenCloudProviderImplementation: ProviderImplementation { + let id: UsageProvider = .qwencloud + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.qwenCloudCookieSource + _ = settings.qwenCloudCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return .qwenCloud(context.settings.qwenCloudSettingsSnapshot()) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.qwenCloudCookieSource.rawValue }, + set: { raw in + context.settings.qwenCloudCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.qwenCloudCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies from Qwen Cloud.", + manual: "Paste a Cookie header from home.qwencloud.com.", + off: "Qwen Cloud cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "qwen-cloud-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies from Qwen Cloud.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.loadForDisplay(provider: .qwencloud) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "qwen-cloud-cookie", + title: "Cookie header", + subtitle: "", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.qwenCloudCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "qwen-cloud-open-dashboard", + title: "Open Token Plan", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(QwenCloudUsageFetcher.dashboardURL) + }), + ], + isVisible: { + context.settings.qwenCloudCookieSource == .manual + }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/QwenCloud/QwenCloudSettingsStore.swift b/Sources/CodexBar/Providers/QwenCloud/QwenCloudSettingsStore.swift new file mode 100644 index 0000000000..ddd79395be --- /dev/null +++ b/Sources/CodexBar/Providers/QwenCloud/QwenCloudSettingsStore.swift @@ -0,0 +1,30 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var qwenCloudCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .qwencloud)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .qwencloud) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .qwencloud, field: "cookieHeader", value: newValue) + } + } + + var qwenCloudCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .qwencloud, fallback: .auto) } + set { + self.updateProviderConfig(provider: .qwencloud) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .qwencloud, field: "cookieSource", value: newValue.rawValue) + } + } + + func qwenCloudSettingsSnapshot() -> ProviderSettingsSnapshot.QwenCloudProviderSettings { + ProviderSettingsSnapshot.QwenCloudProviderSettings( + cookieSource: self.qwenCloudCookieSource, + manualCookieHeader: self.qwenCloudCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 0c13716e7f..2933d7795c 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -23,6 +23,7 @@ enum ProviderImplementationRegistry { case .opencodego: OpenCodeGoProviderImplementation() case .alibaba: AlibabaCodingPlanProviderImplementation() case .alibabatokenplan: AlibabaTokenPlanProviderImplementation() + case .qwencloud: QwenCloudProviderImplementation() case .factory: FactoryProviderImplementation() case .gemini: GeminiProviderImplementation() case .antigravity: AntigravityProviderImplementation() diff --git a/Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg b/Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg new file mode 100644 index 0000000000..2e8609e5de --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 30be9d30d7..7df515afe8 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -969,6 +969,7 @@ extension UsageStore { .opencode: "OpenCode debug log not yet implemented", .alibaba: "Alibaba Coding Plan debug log not yet implemented", .alibabatokenplan: "Alibaba Token Plan debug log not yet implemented", + .qwencloud: "Qwen Cloud debug log not yet implemented", .factory: "Droid debug log not yet implemented", .copilot: "Copilot debug log not yet implemented", .manus: "Manus debug log not yet implemented", @@ -1072,7 +1073,7 @@ extension UsageStore { configToken: nil, hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) - case .clinepass, .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, + case .clinepass, .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .qwencloud, .factory, .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepinfra, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index 34e750039e..22ab65ded2 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -297,6 +297,8 @@ extension CodexBarCLI { switch provider { case .alibabatokenplan: AlibabaTokenPlanSettingsReader.cookieHeader(environment: environment) != nil + case .qwencloud: + QwenCloudSettingsReader.cookieHeader(environment: environment) != nil case .kimi: KimiSettingsReader.authToken(environment: environment) != nil case .manus: diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index b3e07a8f34..d3d3923a76 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -715,6 +715,19 @@ extension CodexBarCLI { { return false } + if provider == .qwencloud, + sourceMode == .auto || sourceMode == .web, + settings?.qwenCloud?.cookieSource != .off + { + let hasEnvironmentCookie = environment.map { + QwenCloudSettingsReader.cookieHeader(environment: $0) != nil + } == true + let hasManualCookie = settings?.qwenCloud?.cookieSource == .manual && + CookieHeaderNormalizer.normalize(settings?.qwenCloud?.manualCookieHeader) != nil + if hasEnvironmentCookie || hasManualCookie { + return false + } + } if provider == .qoder, settings?.qoder?.cookieSource == .manual { diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 3708892916..d7d8ca4d99 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -151,6 +151,7 @@ struct TokenAccountCLIContext { } } + // swiftlint:disable:next cyclomatic_complexity private func makeCookieBackedSnapshot( provider: UsageProvider, account: ProviderTokenAccount?, @@ -187,6 +188,8 @@ struct TokenAccountCLIContext { cookieSource: cookieSettings.cookieSource, manualCookieHeader: cookieSettings.manualCookieHeader, apiRegion: self.resolveAlibabaTokenPlanRegion(config))) + case .qwencloud: + return self.makeSnapshot(qwenCloud: self.makeProviderCookieSettings(cookieSettings)) case .factory: return self.makeSnapshot(factory: self.makeProviderCookieSettings(cookieSettings)) case .minimax: @@ -240,6 +243,7 @@ struct TokenAccountCLIContext { opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings? = nil, alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? = nil, alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings? = nil, + qwenCloud: ProviderSettingsSnapshot.QwenCloudProviderSettings? = nil, factory: ProviderSettingsSnapshot.FactoryProviderSettings? = nil, minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? = nil, manus: ProviderSettingsSnapshot.ManusProviderSettings? = nil, @@ -268,6 +272,7 @@ struct TokenAccountCLIContext { opencodego: opencodego, alibaba: alibaba, alibabaTokenPlan: alibabaTokenPlan, + qwenCloud: qwenCloud, factory: factory, minimax: minimax, manus: manus, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d46bf4b5b2..0f885bdbcb 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "48ac20dad61e9a7f" + static let value = "e8496ca631793ba0" } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift index 183b122af1..aa7b908b3b 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift @@ -1,16 +1,15 @@ import Foundation #if os(macOS) -import CommonCrypto -import Security -import SQLite3 import SweetCookieKit private let alibabaCookieImportOrder: BrowserCookieImportOrder = ProviderDefaults.metadata[.alibaba]?.browserCookieOrder ?? Browser.defaultImportOrder +/// Alibaba-specific entry point for the shared OneConsole cookie importer. public enum AlibabaCodingPlanCookieImporter { - private static let cookieClient = BrowserCookieClient() + public typealias SessionInfo = AliyunOneConsoleCookieImporter.SessionInfo + private static let cookieDomains = [ "bailian-singapore-cs.alibabacloud.com", "bailian-cs.console.aliyun.com", @@ -27,42 +26,6 @@ public enum AlibabaCodingPlanCookieImporter { "aliyun.com", ] - public struct SessionInfo: Sendable { - public let cookies: [HTTPCookie] - public let sourceLabel: String - - public init(cookies: [HTTPCookie], sourceLabel: String) { - self.cookies = cookies - self.sourceLabel = sourceLabel - } - - public var cookieHeader: String { - var byName: [String: HTTPCookie] = [:] - byName.reserveCapacity(self.cookies.count) - - for cookie in self.cookies { - if let expiry = cookie.expiresDate, expiry < Date() { - continue - } - guard !cookie.value.isEmpty else { continue } - if let existing = byName[cookie.name] { - let existingExpiry = existing.expiresDate ?? .distantPast - let candidateExpiry = cookie.expiresDate ?? .distantPast - if candidateExpiry >= existingExpiry { - byName[cookie.name] = cookie - } - } else { - byName[cookie.name] = cookie - } - } - - return byName.keys.sorted().compactMap { name in - guard let cookie = byName[name] else { return nil } - return "\(cookie.name)=\(cookie.value)" - }.joined(separator: "; ") - } - } - #if DEBUG final class ImportSessionOverrideStore: @unchecked Sendable { let importSession: (BrowserDetection, ((String) -> Void)?) throws -> SessionInfo @@ -102,65 +65,21 @@ public enum AlibabaCodingPlanCookieImporter { return try override(browserDetection, logger) } #endif - let log: (String) -> Void = { msg in logger?("[alibaba-cookie] \(msg)") } - var accessDeniedHints: [String] = [] - var failureDetails: [String] = [] - let installedBrowsers = self.cookieImportCandidates(browserDetection: browserDetection) - log("Cookie import candidates: \(installedBrowsers.map(\.displayName).joined(separator: ", "))") - - for browserSource in installedBrowsers { - do { - log("Checking \(browserSource.displayName)") - let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.codexBarRecords( - matching: query, - in: browserSource, - logger: log) - if sources.isEmpty { - log("No matching cookie records in \(browserSource.displayName)") - if let fallbackSession = try Self.importChromiumFallbackSession( - browser: browserSource, - logger: log) - { - return fallbackSession - } - } - for source in sources where !source.records.isEmpty { - let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - if self.isAuthenticatedSession(cookies: httpCookies) { - log("Found \(httpCookies.count) Alibaba cookies in \(source.label)") - return SessionInfo(cookies: httpCookies, sourceLabel: source.label) - } - let cookieNames = Set(httpCookies.map(\.name)) - let hasTicket = cookieNames.contains("login_aliyunid_ticket") - let hasAccount = - cookieNames.contains("login_aliyunid_pk") || - cookieNames.contains("login_current_pk") || - cookieNames.contains("login_aliyunid") - log("Skipping \(source.label): missing auth cookies (ticket=\(hasTicket), account=\(hasAccount))") - } - if let fallbackSession = try Self.importChromiumFallbackSession(browser: browserSource, logger: log) { - return fallbackSession - } - } catch let error as BrowserCookieError { - BrowserCookieAccessGate.recordIfNeeded(error) - if let hint = error.accessDeniedHint { - accessDeniedHints.append(hint) - } - failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") - log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") - } catch { - failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") - log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") - } + do { + return try AliyunOneConsoleCookieImporter.importSession( + browserDetection: browserDetection, + domains: self.cookieDomains, + isAuthenticatedSession: self.isAuthenticatedSession(cookies:), + logPrefix: "alibaba-cookie", + sessionLabel: "Alibaba", + importOrder: alibabaCookieImportOrder, + logger: logger) + } catch let error as AliyunOneConsoleCookieImportError { + throw AlibabaCodingPlanSettingsError.missingCookie(details: error.details) } - - let details = (Array(Set(accessDeniedHints)).sorted() + Array(Set(failureDetails)).sorted()) - .joined(separator: " ") - throw AlibabaCodingPlanSettingsError.missingCookie(details: details.isEmpty ? nil : details) } - private static func isAuthenticatedSession(cookies: [HTTPCookie]) -> Bool { + static func isAuthenticatedSession(cookies: [HTTPCookie]) -> Bool { guard !cookies.isEmpty else { return false } let names = Set(cookies.map(\.name)) let hasTicket = names.contains("login_aliyunid_ticket") @@ -175,22 +94,13 @@ public enum AlibabaCodingPlanCookieImporter { browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> Bool { - do { - _ = try self.importSession(browserDetection: browserDetection, logger: logger) - return true - } catch { - return false - } - } - - private static func importChromiumFallbackSession( - browser: Browser, - logger: ((String) -> Void)? = nil) throws -> SessionInfo? - { - guard browser.usesChromiumProfileStore else { return nil } - return try AlibabaChromiumCookieFallbackImporter.importSession( - browser: browser, + AliyunOneConsoleCookieImporter.hasSession( + browserDetection: browserDetection, domains: self.cookieDomains, + isAuthenticatedSession: self.isAuthenticatedSession(cookies:), + logPrefix: "alibaba-cookie", + sessionLabel: "Alibaba", + importOrder: alibabaCookieImportOrder, logger: logger) } @@ -198,342 +108,17 @@ public enum AlibabaCodingPlanCookieImporter { browserDetection: BrowserDetection, importOrder: BrowserCookieImportOrder = alibabaCookieImportOrder) -> [Browser] { - importOrder.cookieImportCandidates(using: browserDetection) + AliyunOneConsoleCookieImporter.cookieImportCandidates( + browserDetection: browserDetection, + importOrder: importOrder) } static func matchesCookieDomain(_ domain: String, patterns: [String] = Self.cookieDomains) -> Bool { - let normalized = self.normalizeCookieDomain(domain) - return patterns.contains { pattern in - let normalizedPattern = self.normalizeCookieDomain(pattern) - return normalized == normalizedPattern || normalized.hasSuffix(".\(normalizedPattern)") - } + AliyunOneConsoleCookieImporter.matchesCookieDomain(domain, patterns: patterns) } static func normalizeCookieDomain(_ domain: String) -> String { - let trimmed = domain.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = trimmed.hasPrefix(".") ? String(trimmed.dropFirst()) : trimmed - return normalized.lowercased() - } -} - -enum AlibabaChromiumCookieFallbackImporter { - private struct ChromiumCookieRecord { - let domain: String - let name: String - let path: String - let value: String - let expires: Date? - let isSecure: Bool - } - - enum ImportError: LocalizedError { - case keyUnavailable(browser: Browser) - case keychainDenied(browser: Browser) - case sqliteFailed(label: String, details: String) - - var errorDescription: String? { - switch self { - case let .keyUnavailable(browser): - "\(browser.displayName) Safe Storage key not found." - case let .keychainDenied(browser): - "macOS Keychain denied access to \(browser.displayName) Safe Storage." - case let .sqliteFailed(label, details): - "\(label) cookie fallback failed: \(details)" - } - } - } - - static func importSession( - browser: Browser, - domains: [String], - cookieClient: BrowserCookieClient = BrowserCookieClient(), - logger: ((String) -> Void)? = nil) throws -> AlibabaCodingPlanCookieImporter.SessionInfo? - { - let stores = try cookieClient.codexBarStores(for: browser).filter { $0.databaseURL != nil } - guard !stores.isEmpty else { return nil } - - logger?("[alibaba-cookie] Trying \(browser.displayName) Chromium fallback") - let keys = try self.derivedKeys(for: browser) - for store in stores { - let cookies = try self.loadCookies(from: store, domains: domains, keys: keys) - guard !cookies.isEmpty else { continue } - if self.isAuthenticatedSession(cookies) { - logger?("[alibaba-cookie] Found \(cookies.count) Alibaba cookies via \(store.label) fallback") - return AlibabaCodingPlanCookieImporter.SessionInfo(cookies: cookies, sourceLabel: store.label) - } - } - return nil - } - - private static func isAuthenticatedSession(_ cookies: [HTTPCookie]) -> Bool { - let names = Set(cookies.map(\.name)) - let hasTicket = names.contains("login_aliyunid_ticket") - let hasAccount = - names.contains("login_aliyunid_pk") || - names.contains("login_current_pk") || - names.contains("login_aliyunid") - return hasTicket && hasAccount - } - - private static func loadCookies( - from store: BrowserCookieStore, - domains: [String], - keys: [Data]) throws -> [HTTPCookie] - { - guard let sourceDB = store.databaseURL else { return [] } - let records = try self.readCookiesFromLockedDB( - sourceDB: sourceDB, - domains: domains, - keys: keys, - label: store.label) - return records.compactMap(self.makeCookie) - } - - private static func readCookiesFromLockedDB( - sourceDB: URL, - domains: [String], - keys: [Data], - label: String) throws -> [ChromiumCookieRecord] - { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("alibaba-chromium-cookies-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - - let copiedDB = tempDir.appendingPathComponent("Cookies") - try FileManager.default.copyItem(at: sourceDB, to: copiedDB) - for suffix in ["-wal", "-shm"] { - let src = URL(fileURLWithPath: sourceDB.path + suffix) - if FileManager.default.fileExists(atPath: src.path) { - let dst = URL(fileURLWithPath: copiedDB.path + suffix) - try? FileManager.default.copyItem(at: src, to: dst) - } - } - defer { try? FileManager.default.removeItem(at: tempDir) } - - return try self.readCookies(fromDB: copiedDB.path, domains: domains, keys: keys, label: label) - } - - private static func readCookies( - fromDB path: String, - domains: [String], - keys: [Data], - label: String) throws -> [ChromiumCookieRecord] - { - var db: OpaquePointer? - guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { - throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) - } - defer { sqlite3_close(db) } - - let sql = "SELECT host_key, name, path, expires_utc, is_secure, value, encrypted_value FROM cookies" - var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { - throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) - } - defer { sqlite3_finalize(stmt) } - - var records: [ChromiumCookieRecord] = [] - while sqlite3_step(stmt) == SQLITE_ROW { - guard let hostKey = self.readText(stmt, index: 0), self.matches(domain: hostKey, patterns: domains) else { - continue - } - guard let name = self.readText(stmt, index: 1), let path = self.readText(stmt, index: 2) else { - continue - } - - let value: String? = if let plain = self.readText(stmt, index: 5), !plain.isEmpty { - plain - } else if let encrypted = self.readBlob(stmt, index: 6) { - self.decrypt(encrypted, usingAnyOf: keys) - } else { - nil - } - guard let value, !value.isEmpty else { continue } - - records.append(ChromiumCookieRecord( - domain: AlibabaCodingPlanCookieImporter.normalizeCookieDomain(hostKey), - name: name, - path: path, - value: value, - expires: self.chromiumExpiry(sqlite3_column_int64(stmt, 3)), - isSecure: sqlite3_column_int(stmt, 4) != 0)) - } - - return records.filter { record in - guard let expires = record.expires else { return true } - return expires >= Date() - } - } - - private static func derivedKeys(for browser: Browser) throws -> [Data] { - var keys: [Data] = [] - var sawDenied = false - - for label in browser.safeStorageLabels { - switch KeychainAccessPreflight.checkGenericPassword(service: label.service, account: label.account) { - case .interactionRequired: - sawDenied = true - continue - case .allowed, .notFound, .failure: - break - } - - if let password = self.safeStoragePassword(service: label.service, account: label.account) { - keys.append(self.deriveKey(from: password)) - } - } - - if !keys.isEmpty { - return keys - } - if sawDenied { - throw ImportError.keychainDenied(browser: browser) - } - throw ImportError.keyUnavailable(browser: browser) - } - - private static func safeStoragePassword(service: String, account: String) -> String? { - // The preflight classifies prompt-requiring items as .interactionRequired, but its - // .notFound (gate disabled) and .failure outcomes still reach this read. Honor the - // access gate and keep the read strictly non-interactive so it can never prompt. - guard !KeychainAccessGate.isDisabled else { return nil } - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true, - ] - KeychainNoUIQuery.apply(to: &query) - - var result: AnyObject? - let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - private static func deriveKey(from password: String) -> Data { - let salt = Data("saltysalt".utf8) - var key = Data(count: kCCKeySizeAES128) - let keyLength = key.count - _ = key.withUnsafeMutableBytes { keyBytes in - password.utf8CString.withUnsafeBytes { passBytes in - salt.withUnsafeBytes { saltBytes in - CCKeyDerivationPBKDF( - CCPBKDFAlgorithm(kCCPBKDF2), - passBytes.bindMemory(to: Int8.self).baseAddress, - passBytes.count - 1, - saltBytes.bindMemory(to: UInt8.self).baseAddress, - salt.count, - CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), - 1003, - keyBytes.bindMemory(to: UInt8.self).baseAddress, - keyLength) - } - } - } - return key - } - - private static func decrypt(_ encryptedValue: Data, usingAnyOf keys: [Data]) -> String? { - for key in keys { - if let value = self.decrypt(encryptedValue, key: key) { - return value - } - } - return nil - } - - private static func decrypt(_ encryptedValue: Data, key: Data) -> String? { - guard encryptedValue.count > 3 else { return nil } - let prefix = String(data: encryptedValue.prefix(3), encoding: .utf8) - guard prefix == "v10" else { return nil } - - let payload = Data(encryptedValue.dropFirst(3)) - let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) - var outLength = 0 - var out = Data(count: payload.count + kCCBlockSizeAES128) - let outCapacity = out.count - - let status = out.withUnsafeMutableBytes { outBytes in - payload.withUnsafeBytes { payloadBytes in - key.withUnsafeBytes { keyBytes in - iv.withUnsafeBytes { ivBytes in - CCCrypt( - CCOperation(kCCDecrypt), - CCAlgorithm(kCCAlgorithmAES), - CCOptions(kCCOptionPKCS7Padding), - keyBytes.baseAddress, - key.count, - ivBytes.baseAddress, - payloadBytes.baseAddress, - payload.count, - outBytes.baseAddress, - outCapacity, - &outLength) - } - } - } - } - - guard status == kCCSuccess else { return nil } - out.count = outLength - - if let value = String(data: out, encoding: .utf8), !value.isEmpty { - return value - } - if out.count > 32 { - let trimmed = out.dropFirst(32) - if let value = String(data: trimmed, encoding: .utf8), !value.isEmpty { - return value - } - } - return nil - } - - private static func makeCookie(from record: ChromiumCookieRecord) -> HTTPCookie? { - var properties: [HTTPCookiePropertyKey: Any] = [ - .domain: record.domain, - .path: record.path, - .name: record.name, - .value: record.value, - ] - if record.isSecure { - properties[.secure] = true - } - if let expires = record.expires { - properties[.expires] = expires - } - return HTTPCookie(properties: properties) - } - - private static func readText(_ stmt: OpaquePointer?, index: Int32) -> String? { - guard sqlite3_column_type(stmt, index) != SQLITE_NULL, - let value = sqlite3_column_text(stmt, index) - else { - return nil - } - return String(cString: value) - } - - private static func readBlob(_ stmt: OpaquePointer?, index: Int32) -> Data? { - guard sqlite3_column_type(stmt, index) != SQLITE_NULL, - let bytes = sqlite3_column_blob(stmt, index) - else { - return nil - } - return Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) - } - - private static func matches(domain: String, patterns: [String]) -> Bool { - AlibabaCodingPlanCookieImporter.matchesCookieDomain(domain, patterns: patterns) - } - - private static func chromiumExpiry(_ expiresUTC: Int64) -> Date? { - guard expiresUTC > 0 else { return nil } - let seconds = (Double(expiresUTC) / 1_000_000.0) - 11_644_473_600.0 - guard seconds > 0 else { return nil } - return Date(timeIntervalSince1970: seconds) + AliyunOneConsoleCookieImporter.normalizeCookieDomain(domain) } } #endif diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift index 2eb7918ee9..d9f0c8b0ac 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift @@ -859,30 +859,12 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { } private static func expandedJSON(_ value: Any) -> Any { - if let dict = value as? [String: Any] { - var expanded: [String: Any] = [:] - expanded.reserveCapacity(dict.count) - for (key, nested) in dict { - expanded[key] = self.expandedJSON(nested) - } - return expanded - } - if let array = value as? [Any] { - return array.map { self.expandedJSON($0) } - } - if let string = value as? String, - let data = string.data(using: .utf8), - let nested = try? JSONSerialization.jsonObject(with: data, options: []), - nested is [String: Any] || nested is [Any] - { - return self.expandedJSON(nested) - } - return value + OneConsoleJSON.expandEmbeddedJSON(value) } private static func anyInt(for keys: [String], in dict: [String: Any]) -> Int? { for key in keys { - if let value = self.parseInt(dict[key]) { + if let value = OneConsoleJSON.int(dict[key]) { return value } } @@ -891,7 +873,7 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { private static func anyString(for keys: [String], in dict: [String: Any]) -> String? { for key in keys { - if let value = self.parseString(dict[key]) { + if let value = OneConsoleJSON.string(dict[key]) { return value } } @@ -900,7 +882,7 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { private static func anyDate(for keys: [String], in dict: [String: Any]) -> Date? { for key in keys { - if let value = self.parseDate(dict[key]) { + if let value = OneConsoleJSON.date(dict[key]) { return value } } @@ -950,47 +932,15 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { } private static func parseDate(_ raw: Any?) -> Date? { - if let intValue = self.parseInt(raw) { - if intValue > 1_000_000_000_000 { - return Date(timeIntervalSince1970: TimeInterval(intValue) / 1000) - } - if intValue > 1_000_000_000 { - return Date(timeIntervalSince1970: TimeInterval(intValue)) - } - } - if let string = self.parseString(raw) { - let formatter = ISO8601DateFormatter() - if let date = formatter.date(from: string) { - return date - } - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - for format in ["yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"] { - dateFormatter.dateFormat = format - if let date = dateFormatter.date(from: string) { - return date - } - } - } - return nil + OneConsoleJSON.date(raw) } private static func parseInt(_ raw: Any?) -> Int? { - if let value = raw as? Int { return value } - if let value = raw as? Int64 { return Int(value) } - if let value = raw as? Double { return Int(value) } - if let value = raw as? NSNumber { return value.intValue } - if let value = raw as? String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Int(trimmed) - } - return nil + OneConsoleJSON.int(raw) } private 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 + OneConsoleJSON.string(raw) } private static func parsePercent(_ raw: Any?) -> Double? { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift index 14a99af975..bb1cc2a0dc 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift @@ -3,90 +3,49 @@ import Foundation import FoundationNetworking #endif -struct AlibabaTokenPlanCookieHeaders { - private static let cachedAPIHeaderName = "__codexbar_alibaba_token_plan_api" - private static let cachedDashboardHeaderName = "__codexbar_alibaba_token_plan_dashboard" - - let apiCookieHeader: String - let dashboardCookieHeader: String - - init(apiCookieHeader: String, dashboardCookieHeader: String) { - self.apiCookieHeader = apiCookieHeader - self.dashboardCookieHeader = dashboardCookieHeader - } - - init?(singleHeader raw: String?) { - guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } - self.apiCookieHeader = normalized - self.dashboardCookieHeader = normalized - } +/// Alibaba Token Plan cookie-header pair. +/// +/// Wraps the shared `OneConsoleCookieHeaders` so the rest of the codebase +/// keeps referring to `AlibabaTokenPlanCookieHeaders`. The cached-header +/// round-trip uses a namespace unique to Alibaba Token Plan so it can never +/// collide with Qwen Cloud or another provider's cached entry. +public typealias AlibabaTokenPlanCookieHeaders = OneConsoleCookieHeaders + +extension OneConsoleCookieHeaders { + static let alibabaTokenPlanCacheNamespace = "alibaba_token_plan" +} - init?(cachedHeader raw: String?) { - var valuesByName: [String: String] = [:] - for pair in CookieHeaderNormalizer.pairs(from: raw ?? "") { - valuesByName[pair.name] = pair.value - } - if let encodedAPI = valuesByName[Self.cachedAPIHeaderName], - let encodedDashboard = valuesByName[Self.cachedDashboardHeaderName], - let apiHeader = Self.decodeCachedHeader(encodedAPI), - let dashboardHeader = Self.decodeCachedHeader(encodedDashboard), - let normalizedAPI = CookieHeaderNormalizer.normalize(apiHeader), - let normalizedDashboard = CookieHeaderNormalizer.normalize(dashboardHeader) +extension OneConsoleCookieHeaders { + public init?(alibabaTokenPlanCachedHeader raw: String?) { + if let headers = OneConsoleCookieHeaders( + cachedHeader: raw, + cacheNamespace: Self.alibabaTokenPlanCacheNamespace) { - self.init(apiCookieHeader: normalizedAPI, dashboardCookieHeader: normalizedDashboard) - return + self = headers + } else if let headers = OneConsoleCookieHeaders(singleHeader: raw) { + self = headers + } else { + return nil } - - self.init(singleHeader: raw) - } - - var cacheCookieHeader: String { - [ - "\(Self.cachedAPIHeaderName)=\(Self.encodeCachedHeader(self.apiCookieHeader))", - "\(Self.cachedDashboardHeaderName)=\(Self.encodeCachedHeader(self.dashboardCookieHeader))", - ].joined(separator: "; ") - } - - var apiCookieNames: [String] { - Self.cookieNames(from: self.apiCookieHeader) - } - - var dashboardCookieNames: [String] { - Self.cookieNames(from: self.dashboardCookieHeader) - } - - func hasCookie(named name: String) -> Bool { - Self.cookieNames(from: self.apiCookieHeader).contains(name) || - Self.cookieNames(from: self.dashboardCookieHeader).contains(name) - } - - private static func cookieNames(from header: String) -> [String] { - CookieHeaderNormalizer.pairs(from: header) - .map(\.name) - .filter { !$0.isEmpty } - .uniquedSorted() } - private static func encodeCachedHeader(_ header: String) -> String { - Data(header.utf8).base64EncodedString() - } - - private static func decodeCachedHeader(_ encoded: String) -> String? { - guard let data = Data(base64Encoded: encoded) else { return nil } - return String(data: data, encoding: .utf8) + public func cacheAlibabaTokenPlanCookieHeader() -> String { + self.cacheCookieHeader(namespace: Self.alibabaTokenPlanCacheNamespace) } } enum AlibabaTokenPlanCookieHeader { + static let cacheNamespace = OneConsoleCookieHeaders.alibabaTokenPlanCacheNamespace + static func headers( from cookies: [HTTPCookie], region: AlibabaTokenPlanAPIRegion = .international, environment: [String: String] = ProcessInfo.processInfo.environment) -> AlibabaTokenPlanCookieHeaders? { - guard let apiHeader = self.header( + guard let apiHeader = OneConsoleCookieHeaderBuilder.header( from: cookies, targetURL: AlibabaTokenPlanUsageFetcher.resolveQuotaURL(region: region, environment: environment)), - let dashboardHeader = self.header( + let dashboardHeader = OneConsoleCookieHeaderBuilder.header( from: cookies, targetURL: AlibabaTokenPlanUsageFetcher.dashboardURL(region: region, environment: environment)) else { @@ -96,68 +55,6 @@ enum AlibabaTokenPlanCookieHeader { } static func header(from cookies: [HTTPCookie], targetURL: 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 } - - if let existing = byName[cookie.name] { - if self.cookieSortKey(for: cookie) >= self.cookieSortKey(for: existing) { - byName[cookie.name] = cookie - } - } else { - byName[cookie.name] = cookie - } - } - - guard !byName.isEmpty else { return nil } - return byName.keys.sorted().compactMap { name in - guard let cookie = byName[name] else { return nil } - return "\(cookie.name)=\(cookie.value)" - }.joined(separator: "; ") - } - - private static func matchesRequestURL(cookie: HTTPCookie, url: URL) -> Bool { - guard let host = url.host?.lowercased() else { return false } - let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) - guard !normalizedDomain.isEmpty else { return false } - guard host == normalizedDomain || host.hasSuffix(".\(normalizedDomain)") else { return false } - - let cookiePath = cookie.path.isEmpty ? "/" : cookie.path - let requestPath = url.path.isEmpty ? "/" : url.path - if requestPath == cookiePath { - return true - } - guard requestPath.hasPrefix(cookiePath) else { return false } - guard cookiePath != "/" else { return true } - if cookiePath.hasSuffix("/") { - return true - } - guard - let boundaryIndex = requestPath.index( - requestPath.startIndex, - offsetBy: cookiePath.count, - limitedBy: requestPath.endIndex), - boundaryIndex < requestPath.endIndex - else { - return true - } - return requestPath[boundaryIndex] == "/" - } - - private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) { - let pathLength = cookie.path.count - let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) - let domainLength = normalizedDomain.count - let expiry = cookie.expiresDate ?? .distantPast - return (pathLength, domainLength, expiry) - } -} - -extension [String] { - fileprivate func uniquedSorted() -> [String] { - Array(Set(self)).sorted() + OneConsoleCookieHeaderBuilder.header(from: cookies, targetURL: targetURL) } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift index edfd0e645f..a64430d4a9 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -183,7 +183,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { #if os(macOS) if allowCached, let cached = Self.cachedCookieEntry(region: region), - let headers = AlibabaTokenPlanCookieHeaders(cachedHeader: cached.cookieHeader) + let headers = AlibabaTokenPlanCookieHeaders(alibabaTokenPlanCachedHeader: cached.cookieHeader) { Self.log.info( "Alibaba Token Plan using cached browser cookie header", @@ -219,7 +219,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { CookieHeaderCache.store( provider: .alibabatokenplan, scope: region.cookieCacheScope, - cookieHeader: headers.cacheCookieHeader, + cookieHeader: headers.cacheAlibabaTokenPlanCookieHeader(), sourceLabel: session.sourceLabel) Self.log.info( "Alibaba Token Plan imported browser cookies", @@ -277,12 +277,6 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { } } -extension [String] { - fileprivate func uniquedSorted() -> [String] { - Array(Set(self)).sorted() - } -} - extension AlibabaTokenPlanUsageError { fileprivate var isCredentialFailure: Bool { switch self { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 27aee5a635..d16676b83c 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -554,6 +554,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "package_name", "commodityName", "commodity_name", + "specType", + "SpecType", "instanceName", "instance_name", "displayName", @@ -592,6 +594,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "amount", "totalValue", "TotalValue", + "cycleTotalValue", + "CycleTotalValue", ] private static let remainingQuotaKeys = [ "remainingQuota", @@ -607,6 +611,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "TotalSurplusValue", "surplusValue", "SurplusValue", + "cycleSurplusValue", + "CycleSurplusValue", ] private static let subscriptionCountKeys = [ "totalCount", @@ -625,21 +631,34 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "endTime", "validEndTime", "instanceEndTime", + "EndTime", + "cycleEndTime", + "CycleEndTime", "nearestExpireDate", "NearestExpireDate", ] private static func findSubscriptionSummary(in payload: [String: Any]) -> [String: Any]? { + let quotaKeys = Self.usedQuotaKeys + Self.totalQuotaKeys + Self.remainingQuotaKeys if let data = self.findFirstDictionary( forKeys: ["Data", "data", "successResponse", "success_response"], in: payload), self.containsSubscriptionSummaryFields(data) { + // Some consoles (e.g. Qwen Cloud) wrap the quota values inside a nested + // `EquityList` entry while the outer dictionary only carries `TotalCount`. + // Prefer a nested dictionary that actually contains quota numbers so the + // used/total/remaining fields are read from the right level. + if quotaKeys.contains(where: { data[$0] != nil }) { + return data + } + if let nested = self.findFirstDictionary(matchingAnyKey: quotaKeys, in: data) { + return nested + } return data } return self.findFirstDictionary( - matchingAnyKey: Self.usedQuotaKeys + Self.totalQuotaKeys + Self.remainingQuotaKeys + - Self.subscriptionCountKeys, + matchingAnyKey: quotaKeys + Self.subscriptionCountKeys, in: payload) } @@ -804,30 +823,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func expandedJSON(_ value: Any) -> Any { - if let dict = value as? [String: Any] { - var expanded: [String: Any] = [:] - expanded.reserveCapacity(dict.count) - for (key, nested) in dict { - expanded[key] = self.expandedJSON(nested) - } - return expanded - } - if let array = value as? [Any] { - return array.map { self.expandedJSON($0) } - } - if let string = value as? String, - let data = string.data(using: .utf8), - let nested = try? JSONSerialization.jsonObject(with: data, options: []), - nested is [String: Any] || nested is [Any] - { - return self.expandedJSON(nested) - } - return value + OneConsoleJSON.expandEmbeddedJSON(value) } private static func anyString(for keys: [String], in dict: [String: Any]) -> String? { for key in keys { - if let value = self.parseString(dict[key]) { + if let value = OneConsoleJSON.string(dict[key]) { return value } } @@ -845,7 +846,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private static func anyDate(for keys: [String], in dict: [String: Any]) -> Date? { for key in keys { - if let value = self.parseDate(dict[key]) { + if let value = OneConsoleJSON.date(dict[key]) { return value } } @@ -862,12 +863,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func parseInt(_ raw: Any?) -> Int? { - if let value = raw as? Int { return value } - if let value = raw as? Int64 { return Int(value) } - if let value = raw as? Double { return Int(value) } - if let value = raw as? NSNumber { return value.intValue } - if let value = self.parseString(raw) { return Int(value) } - return nil + OneConsoleJSON.int(raw) } private static func parseDouble(_ raw: Any?) -> Double? { @@ -875,7 +871,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { if let value = raw as? Int { return Double(value) } if let value = raw as? Int64 { return Double(value) } if let value = raw as? NSNumber { return value.doubleValue } - if let value = self.parseString(raw) { + if let value = OneConsoleJSON.string(raw) { let cleaned = value.replacingOccurrences(of: ",", with: "") return Double(cleaned) } @@ -883,9 +879,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private 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 + OneConsoleJSON.string(raw) } private static func parseDate(_ raw: Any?) -> Date? { @@ -989,9 +983,3 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return value.isEmpty ? nil : String(value) } } - -extension [String] { - fileprivate func uniquedSorted() -> [String] { - Array(Set(self)).sorted() - } -} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d533368cd7..37475df1bf 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -133,6 +133,7 @@ public enum ProviderDescriptorRegistry { .opencodego: OpenCodeGoProviderDescriptor.descriptor, .alibaba: AlibabaCodingPlanProviderDescriptor.descriptor, .alibabatokenplan: AlibabaTokenPlanProviderDescriptor.descriptor, + .qwencloud: QwenCloudProviderDescriptor.descriptor, .factory: FactoryProviderDescriptor.descriptor, .gemini: GeminiProviderDescriptor.descriptor, .antigravity: AntigravityProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 9c744f589e..49ea597965 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -18,6 +18,7 @@ public struct ProviderSettingsSnapshot: Sendable { opencodego: OpenCodeProviderSettings? = nil, alibaba: AlibabaCodingPlanProviderSettings? = nil, alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, + qwenCloud: QwenCloudProviderSettings? = nil, factory: FactoryProviderSettings? = nil, minimax: MiniMaxProviderSettings? = nil, manus: ManusProviderSettings? = nil, @@ -52,6 +53,7 @@ public struct ProviderSettingsSnapshot: Sendable { opencodego: opencodego, alibaba: alibaba, alibabaTokenPlan: alibabaTokenPlan, + qwenCloud: qwenCloud, factory: factory, minimax: minimax, manus: manus, @@ -198,6 +200,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct QwenCloudProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource = .auto, manualCookieHeader: String? = nil) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct FactoryProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -477,6 +489,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let opencodego: OpenCodeProviderSettings? public let alibaba: AlibabaCodingPlanProviderSettings? public let alibabaTokenPlan: AlibabaTokenPlanProviderSettings? + public let qwenCloud: QwenCloudProviderSettings? public let factory: FactoryProviderSettings? public let minimax: MiniMaxProviderSettings? public let manus: ManusProviderSettings? @@ -515,6 +528,7 @@ public struct ProviderSettingsSnapshot: Sendable { opencodego: OpenCodeProviderSettings?, alibaba: AlibabaCodingPlanProviderSettings?, alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, + qwenCloud: QwenCloudProviderSettings? = nil, factory: FactoryProviderSettings?, minimax: MiniMaxProviderSettings?, manus: ManusProviderSettings?, @@ -548,6 +562,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.opencodego = opencodego self.alibaba = alibaba self.alibabaTokenPlan = alibabaTokenPlan + self.qwenCloud = qwenCloud self.factory = factory self.minimax = minimax self.manus = manus @@ -582,6 +597,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case opencodego(ProviderSettingsSnapshot.OpenCodeProviderSettings) case alibaba(ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings) case alibabaTokenPlan(ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings) + case qwenCloud(ProviderSettingsSnapshot.QwenCloudProviderSettings) case factory(ProviderSettingsSnapshot.FactoryProviderSettings) case minimax(ProviderSettingsSnapshot.MiniMaxProviderSettings) case manus(ProviderSettingsSnapshot.ManusProviderSettings) @@ -617,6 +633,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings? public var alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? public var alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings? + public var qwenCloud: ProviderSettingsSnapshot.QwenCloudProviderSettings? public var factory: ProviderSettingsSnapshot.FactoryProviderSettings? public var minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? public var manus: ProviderSettingsSnapshot.ManusProviderSettings? @@ -656,6 +673,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .opencodego(value): self.opencodego = value case let .alibaba(value): self.alibaba = value case let .alibabaTokenPlan(value): self.alibabaTokenPlan = value + case let .qwenCloud(value): self.qwenCloud = value case let .factory(value): self.factory = value case let .minimax(value): self.minimax = value case let .manus(value): self.manus = value @@ -693,6 +711,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { opencodego: self.opencodego, alibaba: self.alibaba, alibabaTokenPlan: self.alibabaTokenPlan, + qwenCloud: self.qwenCloud, factory: self.factory, minimax: self.minimax, manus: self.manus, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 4953c94b07..bedb6c5226 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -13,6 +13,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case opencodego case alibaba case alibabatokenplan + case qwencloud case factory case gemini case antigravity @@ -84,6 +85,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case opencode case opencodego case alibaba + case qwencloud case factory case copilot case devin diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieHeader.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieHeader.swift new file mode 100644 index 0000000000..3605bdb8c6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieHeader.swift @@ -0,0 +1,54 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Qwen Cloud cookie-header pair. +/// +/// Wraps the shared `OneConsoleCookieHeaders` so the rest of the codebase +/// keeps referring to `QwenCloudCookieHeaders`. The cached-header round-trip +/// uses a namespace unique to Qwen Cloud so it can never collide with +/// Alibaba Token Plan or another provider's cached entry. +public typealias QwenCloudCookieHeaders = OneConsoleCookieHeaders + +extension OneConsoleCookieHeaders { + static let qwenCloudCacheNamespace = "qwen_cloud" + + public init?(qwenCloudCachedHeader raw: String?) { + if let headers = OneConsoleCookieHeaders(cachedHeader: raw, cacheNamespace: Self.qwenCloudCacheNamespace) { + self = headers + } else if let headers = OneConsoleCookieHeaders(singleHeader: raw) { + self = headers + } else { + return nil + } + } + + public func cacheQwenCloudCookieHeader() -> String { + self.cacheCookieHeader(namespace: Self.qwenCloudCacheNamespace) + } +} + +enum QwenCloudCookieHeader { + static let cacheNamespace = OneConsoleCookieHeaders.qwenCloudCacheNamespace + + static func headers( + from cookies: [HTTPCookie], + environment: [String: String] = ProcessInfo.processInfo.environment) -> QwenCloudCookieHeaders? + { + guard let apiHeader = OneConsoleCookieHeaderBuilder.header( + from: cookies, + targetURL: QwenCloudUsageFetcher.resolveQuotaURL(environment: environment)), + let dashboardHeader = OneConsoleCookieHeaderBuilder.header( + from: cookies, + targetURL: QwenCloudUsageFetcher.dashboardURL(environment: environment)) + else { + return nil + } + return QwenCloudCookieHeaders(apiCookieHeader: apiHeader, dashboardCookieHeader: dashboardHeader) + } + + static func header(from cookies: [HTTPCookie], targetURL: URL) -> String? { + OneConsoleCookieHeaderBuilder.header(from: cookies, targetURL: targetURL) + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieImporter.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieImporter.swift new file mode 100644 index 0000000000..8f2c72a69c --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieImporter.swift @@ -0,0 +1,60 @@ +#if os(macOS) +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import SweetCookieKit + +/// Qwen Cloud (home.qwencloud.com) shares the Aliyun one-console auth backend, so its +/// session cookies live on `qwencloud.com` plus the alibabacloud/aliyun passport domains. +public enum QwenCloudCookieImport { + static let cookieDomains: [String] = [ + "qwencloud.com", + "home.qwencloud.com", + "account.qwencloud.com", + "signin.qwencloud.com", + "www.qwencloud.com", + "alibabacloud.com", + "account.alibabacloud.com", + "aliyun.com", + "console.aliyun.com", + ] + + /// Cookie names that prove an authenticated Qwen Cloud session. Locale + /// preferences, account-id markers, and CSRF tokens are intentionally + /// excluded: a browser profile that merely visited qwencloud.com already + /// carries them while logged out, and treating such a profile as + /// authenticated would make the fetcher send a ticketless request, receive + /// `loginRequired`, and keep re-importing the same profile forever. + static let authTicketCookies: Set = [ + "login_aliyunid_ticket", + "login_qwencloud_ticket", + "qwen_sso_ticket", + ] + + public static func importSession( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil, + importOrder: BrowserCookieImportOrder = Browser.defaultImportOrder) + throws -> AliyunOneConsoleCookieImporter.SessionInfo + { + try AliyunOneConsoleCookieImporter.importSession( + browserDetection: browserDetection, + domains: self.cookieDomains, + isAuthenticatedSession: self.isAuthenticatedSession(cookies:), + logPrefix: "qwen-cloud-cookie", + sessionLabel: "Qwen Cloud", + importOrder: importOrder, + logger: logger) + } + + static func isAuthenticatedSession(cookies: [HTTPCookie]) -> Bool { + // Qwen Cloud uses its own login ticket for direct accounts and the + // alibabacloud passport ticket for legacy/federated accounts. Accept SSO + // tickets too so SAML/SSO logins work. Never accept locale/account-id + // cookies on their own — logged-out profiles carry them as well. + let names = Set(cookies.map(\.name)) + return !names.isDisjoint(with: self.authTicketCookies) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudHTTPTransport.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudHTTPTransport.swift new file mode 100644 index 0000000000..b195f8de50 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudHTTPTransport.swift @@ -0,0 +1,52 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Qwen Cloud transport that applies its OneConsole redirect policy per request. +/// +/// Redirect behavior stays provider-local: the shared provider client blocks +/// cross-origin redirects, while Qwen may follow credential-free login navigation. +struct QwenCloudHTTPTransport: ProviderHTTPTransport, @unchecked Sendable { + private static let sharedSession: URLSession = { + let configuration = ProviderHTTPClient.defaultConfiguration() + configuration.httpCookieStorage = nil + configuration.urlCredentialStorage = nil + configuration.httpShouldSetCookies = false + return URLSession(configuration: configuration) + }() + + private let session: URLSession + private let routing: OneConsoleCookieRouting + + init(session: URLSession? = nil, routing: OneConsoleCookieRouting) { + self.session = session ?? Self.sharedSession + self.routing = routing + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let delegate = RedirectDelegate(routing: self.routing) + return try await self.session.data(for: request, delegate: delegate) + } + + private final class RedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let routing: OneConsoleCookieRouting + + init(routing: OneConsoleCookieRouting) { + self.routing = routing + } + + func urlSession( + _: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest) async -> URLRequest? + { + guard let original = task.originalRequest else { return nil } + return self.routing.redirectedRequest( + forRedirectFrom: original, + response: response, + to: request) + } + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift new file mode 100644 index 0000000000..ffd88045b4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift @@ -0,0 +1,255 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit +#endif + +public enum QwenCloudProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + #if os(macOS) + let browserOrder: BrowserCookieImportOrder = [.chrome] + #else + let browserOrder: BrowserCookieImportOrder? = nil + #endif + + return ProviderDescriptor( + id: .qwencloud, + metadata: ProviderMetadata( + id: .qwencloud, + displayName: "Qwen Cloud", + sessionLabel: "5-hour", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Qwen Cloud usage", + cliName: "qwen-cloud", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: browserOrder, + dashboardURL: QwenCloudUsageFetcher.dashboardURL.absoluteString, + statusPageURL: nil, + statusLinkURL: "https://status.alibabacloud.com"), + branding: ProviderBranding( + iconStyle: .qwencloud, + iconResourceName: "ProviderIcon-qwencloud", + color: ProviderColor(hex: 0x615CED), + confettiPalette: [ + ProviderColor(hex: 0x615CED), + ProviderColor(hex: 0x8B86F5), + ProviderColor(hex: 0xFFFFFF), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Qwen Cloud cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), + cli: ProviderCLIConfig( + name: "qwen-cloud", + aliases: ["qwencloud", "qwen", "qwen-token-plan"], + versionDetector: nil)) + } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + guard context.settings?.qwenCloud?.cookieSource != .off else { return [] } + switch context.sourceMode { + case .auto, .web: + return [QwenCloudWebFetchStrategy()] + case .api, .cli, .oauth: + return [] + } + } +} + +struct QwenCloudWebFetchStrategy: ProviderFetchStrategy { + private static let log = CodexBarLog.logger("qwen-cloud") + + #if os(macOS) + static let browserOrder: BrowserCookieImportOrder = [.chrome] + #endif + + let id: String = "qwen-cloud.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.qwenCloud?.cookieSource != .off else { return false } + + if QwenCloudSettingsReader.cookieHeader(environment: context.env) != nil { + return true + } + + if let settings = context.settings?.qwenCloud, + settings.cookieSource == .manual + { + return CookieHeaderNormalizer.normalize(settings.manualCookieHeader) != nil + } + + #if os(macOS) + if let cached = CookieHeaderCache.load(provider: .qwencloud), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.qwenCloud?.cookieSource ?? .auto + let cookieHeaders = try Self.resolveCookieHeaders(context: context, allowCached: true) + do { + let usage = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: cookieHeaders.apiCookieHeader, + dashboardCookieHeader: cookieHeaders.dashboardCookieHeader, + environment: context.env) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") + } catch let error as QwenCloudUsageError + where error.isCredentialFailure && cookieSource != .manual + { + #if os(macOS) + CookieHeaderCache.clear(provider: .qwencloud) + let refreshedHeaders = try Self.resolveCookieHeaders(context: context, allowCached: false) + let usage = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: refreshedHeaders.apiCookieHeader, + dashboardCookieHeader: refreshedHeaders.dashboardCookieHeader, + environment: context.env) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") + #else + throw error + #endif + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { + try self.resolveCookieHeaders(context: context, allowCached: allowCached).apiCookieHeader + } + + static func resolveCookieHeaders( + context: ProviderFetchContext, + allowCached: Bool) throws -> QwenCloudCookieHeaders + { + if let settings = context.settings?.qwenCloud, + settings.cookieSource == .manual + { + guard let headers = QwenCloudCookieHeaders(singleHeader: settings.manualCookieHeader) else { + self.log.warning("Qwen Cloud manual cookie header is invalid") + throw QwenCloudSettingsError.invalidCookie + } + Self.log.info( + "Qwen Cloud using manual cookie header", + metadata: [ + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + ]) + return headers + } + + if let envCookie = QwenCloudSettingsReader.cookieHeader(environment: context.env), + let headers = QwenCloudCookieHeaders(singleHeader: envCookie) + { + Self.log.info( + "Qwen Cloud using environment cookie header", + metadata: [ + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + ]) + return headers + } + + #if os(macOS) + if allowCached, + let cached = CookieHeaderCache.load(provider: .qwencloud), + let headers = QwenCloudCookieHeaders(qwenCloudCachedHeader: cached.cookieHeader) + { + Self.log.info( + "Qwen Cloud using cached browser cookie header", + metadata: [ + "source": cached.sourceLabel, + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + ]) + return headers + } + + do { + var importLog: [String] = [] + let session = try QwenCloudCookieImport.importSession( + browserDetection: context.browserDetection, + logger: { importLog.append($0) }, + importOrder: Self.browserOrder) + let rawCookieNames = session.cookies.map(\.name).filter { !$0.isEmpty }.uniquedSorted() + guard let headers = QwenCloudCookieHeader.headers( + from: session.cookies, + environment: context.env) + else { + Self.log.warning( + "Qwen Cloud browser cookie header was empty", + metadata: [ + "source": session.sourceLabel, + "rawCookieNames": rawCookieNames.joined(separator: ","), + ]) + throw QwenCloudSettingsError.missingCookie( + details: "No Qwen Cloud browser cookies were available after import.") + } + CookieHeaderCache.store( + provider: .qwencloud, + cookieHeader: headers.cacheQwenCloudCookieHeader(), + sourceLabel: session.sourceLabel) + Self.log.info( + "Qwen Cloud imported browser cookies", + metadata: [ + "source": session.sourceLabel, + "rawCookieNames": rawCookieNames.joined(separator: ","), + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + "importLogLines": "\(importLog.count)", + ]) + return headers + } catch { + Self.log.warning( + "Qwen Cloud cookie resolution failed", + metadata: ["error": error.localizedDescription]) + throw QwenCloudSettingsError.missingCookie(details: Self.missingCookieDetails(from: error)) + } + #else + throw QwenCloudSettingsError.missingCookie() + #endif + } + + private static func missingCookieDetails(from error: Error) -> String? { + if let error = error as? AliyunOneConsoleCookieImportError { + return error.details + } + if case let AlibabaCodingPlanSettingsError.missingCookie(details) = error { + return details + } + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? nil : message + } +} + +extension QwenCloudUsageError { + fileprivate var isCredentialFailure: Bool { + switch self { + case .loginRequired, .invalidCredentials: + true + case .apiError, .networkError, .parseFailed: + false + } + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift new file mode 100644 index 0000000000..3acc78a145 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift @@ -0,0 +1,65 @@ +import Foundation + +public struct QwenCloudSettingsReader: Sendable { + public static let cookieHeaderKey = "QWEN_CLOUD_COOKIE" + public static let hostKey = "QWEN_CLOUD_HOST" + public static let quotaURLKey = "QWEN_CLOUD_QUOTA_URL" + + public static func cookieHeader( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.cookieHeaderKey]) + } + + public static func hostOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + guard let raw = self.cleaned(environment[self.hostKey]) else { return nil } + // Accept full https:// URLs and normalize bare hosts (e.g. "qwen-cloud.test" + // or "qwen-cloud.test:8443") to HTTPS, so dashboardURL / defaultQuotaURL + // always build valid URLs. Mirrors the shared endpoint-override rules used + // by the Alibaba token-plan host override. + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw)?.absoluteString + } + + public static func quotaURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? + { + guard let raw = self.cleaned(environment[self.quotaURLKey]) else { return nil } + if let url = URL(string: raw), let scheme = url.scheme { + return scheme.lowercased() == "https" ? url : nil + } + return URL(string: "https://\(raw)") + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} + +public enum QwenCloudSettingsError: LocalizedError, Sendable { + case missingCookie(details: String? = nil) + case invalidCookie + + public var errorDescription: String? { + switch self { + case let .missingCookie(details): + let base = "No Qwen Cloud session cookies found in browsers. " + + "Sign in to Qwen Cloud in Chrome, allow CodexBar to access Chrome Safe Storage in Keychain Access, " + + "or paste a manual Cookie header." + guard let details, !details.isEmpty else { return base } + return "\(base) \(details)" + case .invalidCookie: + return "Qwen Cloud cookie header is invalid." + } + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift new file mode 100644 index 0000000000..b3a34e3a43 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift @@ -0,0 +1,169 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Qwen Cloud token-plan gateway client. +/// +/// Owns request construction (cornerstoneParam envelope, CSRF / cookie / +/// origin / referer headers, percent-encoded form body) for the three +/// `zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/*` endpoints the +/// dashboard exposes. Endpoint URLs and required authentication metadata +/// (sec_token) come from the caller; the client does not own the session +/// itself. +struct QwenCloudTokenPlanAPIClient: Sendable { + private static let log = CodexBarLog.logger("qwen-cloud") + struct Context: Sendable { + let secToken: String + let secTokenSource: String + let environment: [String: String] + let apiCookieHeader: String + let dashboardURL: URL + } + + let transport: any ProviderHTTPTransport + + func fetch( + api: String, + dataParameters: [String: String], + context: Context) async throws -> Data + { + let url = QwenCloudUsageFetcher.resolveAPIURL(api: api, environment: context.environment) + guard let host = url.host else { + throw QwenCloudUsageError.networkError("Invalid quota URL") + } + + let request = try self.makeRequest( + api: api, + dataParameters: dataParameters, + context: context, + url: url) + Self.log.info( + "Fetching Qwen Cloud token plan API", + metadata: [ + "api": api, + "apiHost": host, + "apiCookieNames": Self.cookieNames(from: context.apiCookieHeader) + .joined(separator: ","), + "hasCSRF": Self.cookieValue(named: "login_aliyunid_csrf", in: context.apiCookieHeader) == nil + ? "0" : "1", + "secTokenSource": context.secTokenSource, + ]) + + let (data, response) = try await self.transport.data(for: request) + return try Self.processResponse(data: data, response: response) + } + + func fetchOptional( + api: String, + dataParameters: [String: String], + context: Context) async -> Data? + { + do { + return try await self.fetch(api: api, dataParameters: dataParameters, context: context) + } catch { + Self.log.warning( + "Optional Qwen Cloud token plan metadata fetch failed", + metadata: ["api": api, "error": error.localizedDescription]) + return nil + } + } + + private func makeRequest( + api: String, + dataParameters: [String: String], + context: Context, + url: URL) throws -> URLRequest + { + var cornerstone: [String: Any] = [ + "feTraceId": UUID().uuidString.lowercased(), + "feURL": context.dashboardURL.absoluteString, + "protocol": "V2", + "console": "ONE_CONSOLE", + "productCode": "p_efm", + "domain": context.dashboardURL.host ?? "home.qwencloud.com", + "consoleSite": "QWENCLOUD", + "userNickName": "", + "userPrincipalName": "", + "xsp_lang": QwenCloudUsageFetcher.language, + ] + if let anonymousID = Self.cookieValue(named: "cna", in: context.apiCookieHeader) { + cornerstone["X-Anonymous-Id"] = anonymousID + } + var apiData = dataParameters as [String: Any] + apiData["cornerstoneParam"] = cornerstone + let params: [String: Any] = [ + "Api": api, + "V": "1.0", + "Data": apiData, + ] + let paramsData = try JSONSerialization.data(withJSONObject: params) + guard let paramsJSON = String(data: paramsData, encoding: .utf8) else { + throw QwenCloudUsageError.parseFailed("Could not encode request parameters") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(context.apiCookieHeader, forHTTPHeaderField: "Cookie") + request.setValue( + QwenCloudUsageFetcher.gatewayHostBase(environment: context.environment), + forHTTPHeaderField: "Origin") + request.setValue(context.dashboardURL.absoluteString, forHTTPHeaderField: "Referer") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + if let csrf = Self.cookieValue(named: "login_aliyunid_csrf", in: context.apiCookieHeader) ?? + Self.cookieValue(named: "csrf", in: context.apiCookieHeader) + { + request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") + request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") + } + + var body = URLComponents() + body.queryItems = [ + URLQueryItem(name: "product", value: QwenCloudUsageFetcher.consoleProduct), + URLQueryItem(name: "action", value: QwenCloudUsageFetcher.consoleAction), + URLQueryItem(name: "sec_token", value: context.secToken), + URLQueryItem(name: "region", value: QwenCloudUsageFetcher.region), + URLQueryItem(name: "language", value: QwenCloudUsageFetcher.language), + URLQueryItem(name: "params", value: paramsJSON), + ] + request.httpBody = Data((body.percentEncodedQuery ?? "").utf8) + return request + } + + private static func processResponse(data: Data, response: URLResponse) throws -> Data { + if let http = response as? HTTPURLResponse { + QwenCloudUsageFetcher.log.info( + "Qwen Cloud HTTP response", + metadata: [ + "status": "\(http.statusCode)", + "contentType": http.value(forHTTPHeaderField: "Content-Type") ?? "unknown", + "bodyBytes": "\(data.count)", + ]) + switch http.statusCode { + case 200: + return data + case 401, 403: + throw QwenCloudUsageError.invalidCredentials + default: + throw QwenCloudUsageError.apiError("HTTP \(http.statusCode)") + } + } + return data + } + + private static func cookieValue(named name: String, in header: String) -> String? { + CookieHeaderNormalizer.pairs(from: header) + .first { $0.name.caseInsensitiveCompare(name) == .orderedSame }? + .value + } + + private static func cookieNames(from header: String) -> [String] { + CookieHeaderNormalizer.pairs(from: header) + .map(\.name) + .filter { !$0.isEmpty } + .uniquedSorted() + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageFetcher.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageFetcher.swift new file mode 100644 index 0000000000..fe33a5dbd1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageFetcher.swift @@ -0,0 +1,249 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum QwenCloudUsageError: LocalizedError, Equatable { + case loginRequired + case invalidCredentials + case apiError(String) + case networkError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .loginRequired: + "Qwen Cloud login required. Sign in to Qwen Cloud in your browser and try again." + case .invalidCredentials: + "Qwen Cloud rejected the stored session. Re-import or re-paste your Cookie header." + case let .apiError(message): + "Qwen Cloud usage API error: \(message)" + case let .networkError(message): + "Qwen Cloud network error: \(message)" + case let .parseFailed(message): + "Failed to parse Qwen Cloud usage: \(message)" + } + } +} + +/// Orchestrator for Qwen Cloud token-plan usage fetches. +/// +/// Owns: dashboard / API URL resolution, host-override normalization, and +/// the sec_token -> three API calls -> snapshot flow. Request construction +/// lives in `QwenCloudTokenPlanAPIClient`; response parsing lives in +/// `QwenCloudUsageParser`. +public struct QwenCloudUsageFetcher: Sendable { + public static let gatewayBaseURLString = "https://home.qwencloud.com" + static let dataGatewayBaseURLString = "https://cs-data.qwencloud.com" + /// Qwen Cloud international "token plan (individual)" product code. + static let productCode = "sfm_tokenplansolo_public_intl" + static let consoleProduct = "sfm_bailian" + static let consoleAction = "IntlBroadScopeAspnGateway" + static let usageAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + static let subscriptionAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription" + static let quotaConfigAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config" + static let region = "ap-southeast-1" + static let language = "en-US" + + static let log = CodexBarLog.logger("qwen-cloud") + + public static var dashboardURL: URL { + self.dashboardURL(environment: ProcessInfo.processInfo.environment) + } + + public static func dashboardURL(environment: [String: String]) -> URL { + if let override = QwenCloudSettingsReader.hostOverride(environment: environment) { + let base = override.hasSuffix("/") ? String(override.dropLast()) : override + if let url = URL(string: "\(base)/billing/subscription/token-plan-individual") { + return url + } + } + return URL(string: "\(self.gatewayBaseURLString)/billing/subscription/token-plan-individual")! + } + + public static var defaultQuotaURL: URL { + self.defaultQuotaURL(environment: ProcessInfo.processInfo.environment) + } + + public static func defaultQuotaURL(environment: [String: String]) -> URL { + self.defaultAPIURL(api: self.usageAPI, environment: environment) + } + + public static func resolveQuotaURL(environment: [String: String]) -> URL { + self.resolveAPIURL(api: self.usageAPI, environment: environment) + } + + static func resolveAPIURL(api: String, environment: [String: String]) -> URL { + if let override = QwenCloudSettingsReader.quotaURL(environment: environment) { + return override + } + return self.defaultAPIURL(api: api, environment: environment) + } + + fileprivate static func defaultAPIURL(api: String, environment: [String: String]) -> URL { + let base = self.dataGatewayHostBase(environment: environment) + var components = URLComponents(string: "\(base)/data/api.json")! + components.queryItems = [ + URLQueryItem(name: "action", value: self.consoleAction), + URLQueryItem(name: "product", value: self.consoleProduct), + URLQueryItem(name: "api", value: api), + URLQueryItem(name: "_v", value: "undefined"), + ] + return components.url! + } + + static func gatewayHostBase(environment: [String: String]) -> String { + if let override = QwenCloudSettingsReader.hostOverride(environment: environment) { + return override.hasSuffix("/") ? String(override.dropLast()) : override + } + return self.gatewayBaseURLString + } + + fileprivate static func dataGatewayHostBase(environment: [String: String]) -> String { + if let override = QwenCloudSettingsReader.hostOverride(environment: environment) { + return override.hasSuffix("/") ? String(override.dropLast()) : override + } + return self.dataGatewayBaseURLString + } + + private static let secTokenResolver = OneConsoleSECTokenResolver( + configuration: OneConsoleSECTokenResolver.Configuration( + dashboardURL: { QwenCloudUsageFetcher.dashboardURL(environment: $0) }, + userInfoPath: "/tool/user/info.json", + isLoginPage: QwenCloudUsageFetcher.looksLikeLoginPage)) + + public static func fetchUsage( + apiCookieHeader rawCookieHeader: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + transport: (any ProviderHTTPTransport)? = nil) async throws -> QwenCloudUsageSnapshot + { + try await self.fetchUsage( + apiCookieHeader: rawCookieHeader, + dashboardCookieHeader: rawCookieHeader, + environment: environment, + now: now, + transport: transport) + } + + public static func fetchUsage( + apiCookieHeader: String, + dashboardCookieHeader: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + transport: (any ProviderHTTPTransport)? = nil) async throws -> QwenCloudUsageSnapshot + { + let normalizedAPI = CookieHeaderNormalizer.normalize(apiCookieHeader) + let normalizedDashboard = CookieHeaderNormalizer.normalize(dashboardCookieHeader) ?? normalizedAPI + guard let cookieHeader = normalizedAPI, !cookieHeader.isEmpty else { + throw QwenCloudSettingsError.invalidCookie + } + let dashboardHeader = normalizedDashboard ?? cookieHeader + + let usageURL = self.resolveQuotaURL(environment: environment) + guard let host = usageURL.host else { + throw QwenCloudUsageError.networkError("Invalid quota URL") + } + let dashboardURL = self.dashboardURL(environment: environment) + guard dashboardURL.host != nil else { + throw QwenCloudUsageError.networkError("Invalid dashboard URL") + } + let activeTransport: any ProviderHTTPTransport = transport ?? QwenCloudHTTPTransport( + routing: OneConsoleCookieRouting( + apiURL: usageURL, + dashboardURL: dashboardURL, + apiCookieHeader: cookieHeader, + dashboardCookieHeader: dashboardHeader)) + let resolved: OneConsoleSECTokenResolver.Resolved + do { + resolved = try await self.secTokenResolver.resolve( + cookieHeader: dashboardHeader, + environment: environment, + transport: activeTransport) + } catch OneConsoleSECTokenError.notFound { + throw QwenCloudUsageError.loginRequired + } catch { + throw QwenCloudUsageError.networkError(error.localizedDescription) + } + Self.log.info("Resolved Qwen Cloud sec_token from \(resolved.source.rawValue)") + + let client = QwenCloudTokenPlanAPIClient(transport: activeTransport) + let context = QwenCloudTokenPlanAPIClient.Context( + secToken: resolved.value, + secTokenSource: resolved.source.rawValue, + environment: environment, + apiCookieHeader: cookieHeader, + dashboardURL: dashboardURL) + + let usageData: Data + do { + usageData = try await client.fetch( + api: self.usageAPI, + dataParameters: [:], + context: context) + } catch let error as QwenCloudUsageError { + throw error + } catch { + throw QwenCloudUsageError.networkError(error.localizedDescription) + } + + let subscriptionData = await client.fetchOptional( + api: self.subscriptionAPI, + dataParameters: ["commodityCode": self.productCode], + context: context) + let quotaConfigData = await client.fetchOptional( + api: self.quotaConfigAPI, + dataParameters: [:], + context: context) + + do { + return try QwenCloudUsageParser.parse( + from: usageData, + subscriptionData: subscriptionData, + quotaConfigData: quotaConfigData, + now: now) + } catch let error as QwenCloudUsageError { + let contentType = Self.contentType(of: usageData) + let bodyPreview = String(data: usageData.prefix(200), encoding: .utf8)? + .replacingOccurrences(of: "\n", with: " ") ?? "<\(usageData.count) bytes>" + Self.log.warning( + "Qwen Cloud usage parse failed", + metadata: [ + "apiHost": host, + "status": "200", + "contentType": contentType ?? "unknown", + "bodyPreview": bodyPreview, + "error": "\(error)", + ]) + throw error + } + } + + private static func contentType(of data: Data) -> String? { + let head = data.prefix(64) + if head.contains(0x7B) || head.contains(0x5B) { + return "application/json" + } + let prefix = String(data: head, encoding: .utf8)?.lowercased() ?? "" + if prefix.hasPrefix(" Bool { + let lowered = html.lowercased() + return lowered.contains("passport.alibabacloud.com") || + lowered.contains("signin.aliyun.com") || + lowered.contains("account.alibabacloud.com/login") || + lowered.contains("login.qwencloud.com") || + (lowered.contains("login") && lowered.contains("password") && lowered.contains("sign in")) + } + + public static func parseUsageSnapshot( + from data: Data, + now: Date = Date()) throws -> QwenCloudUsageSnapshot + { + try QwenCloudUsageParser.parseUsageSnapshot(from: data, now: now) + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift new file mode 100644 index 0000000000..e2709b6af0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift @@ -0,0 +1,139 @@ +import Foundation + +/// Parses Qwen Cloud token-plan responses (current shape) and falls back to +/// the legacy Alibaba subscription-summary envelope when the current shape +/// does not contain usable usage data. +enum QwenCloudUsageParser { + static func parse( + from usageData: Data, + subscriptionData: Data?, + quotaConfigData: Data?, + now: Date) throws -> QwenCloudUsageSnapshot + { + if let snapshot = try self.parseCurrentTokenPlanUsage( + from: usageData, + subscriptionData: subscriptionData, + quotaConfigData: quotaConfigData, + now: now) + { + return snapshot + } + do { + let alibaba = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: usageData, now: now) + return QwenCloudUsageSnapshot(alibabaSnapshot: alibaba) + } catch let error as AlibabaTokenPlanUsageError { + throw Self.map(error) + } + } + + static func parseUsageSnapshot(from data: Data, now: Date = Date()) throws -> QwenCloudUsageSnapshot { + try self.parse(from: data, subscriptionData: nil, quotaConfigData: nil, now: now) + } + + private static func parseCurrentTokenPlanUsage( + from data: Data, + subscriptionData: Data?, + quotaConfigData: Data?, + now: Date) throws -> QwenCloudUsageSnapshot? + { + let raw: Any + do { + raw = try JSONSerialization.jsonObject(with: data) + } catch { + return nil + } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let usage = OneConsoleJSON.findObject( + containingAnyOf: ["per5HourPercentage", "per1WeekPercentage"], + in: expanded) + else { + return nil + } + + let fiveHourPercent = OneConsoleJSON.percentagePoints( + fromRatio: OneConsoleJSON.number(usage["per5HourPercentage"])) + let weeklyPercent = OneConsoleJSON.percentagePoints( + fromRatio: OneConsoleJSON.number(usage["per1WeekPercentage"])) + guard fiveHourPercent != nil || weeklyPercent != nil else { return nil } + let planCode = subscriptionData.flatMap(self.planCode) + let planName = planCode.map(self.displayPlanName) + let quota = quotaConfigData.flatMap { + self.quotaTotals(from: $0, planCode: planCode) + } + + return QwenCloudUsageSnapshot( + planName: planName, + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + fiveHourUsedPercent: fiveHourPercent, + fiveHourTotalQuota: quota?.fiveHour, + fiveHourResetsAt: OneConsoleJSON.date(usage["per5HourResetTime"]), + weeklyUsedPercent: weeklyPercent, + weeklyTotalQuota: quota?.weekly, + weeklyResetsAt: OneConsoleJSON.date(usage["per1WeekResetTime"]), + updatedAt: now) + } + + private static func planCode(from data: Data) -> String? { + guard let raw = try? JSONSerialization.jsonObject(with: data) else { return nil } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let plan = OneConsoleJSON.findObject( + containingAnyOf: ["specCode", "spec_code", "planName", "plan_name"], + in: expanded) + else { + return nil + } + for key in ["specCode", "spec_code", "planName", "plan_name"] { + if let value = plan[key] as? String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if !normalized.isEmpty { + return normalized + } + } + } + return nil + } + + private static func displayPlanName(_ planCode: String) -> String { + switch planCode { + case "lite": "Lite" + case "standard": "Standard" + case "pro": "Pro" + case "max": "Max" + default: planCode + } + } + + private static func quotaTotals( + from data: Data, + planCode: String?) -> (fiveHour: Double?, weekly: Double?)? + { + guard let planCode, + let raw = try? JSONSerialization.jsonObject(with: data) + else { + return nil + } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let value = OneConsoleJSON.findFirstValue(forKeys: [planCode], in: expanded), + let quota = value as? [String: Any] + else { + return nil + } + let fiveHour = OneConsoleJSON.number(quota["five_hour"] ?? quota["fiveHour"]) + let weekly = OneConsoleJSON.number(quota["weekly"]) + guard fiveHour != nil || weekly != nil else { return nil } + return (fiveHour, weekly) + } + + private static func map(_ error: AlibabaTokenPlanUsageError) -> QwenCloudUsageError { + switch error { + case .loginRequired: .loginRequired + case .invalidCredentials: .invalidCredentials + case let .apiError(message): .apiError(message) + case let .networkError(message): .networkError(message) + case let .parseFailed(message): .parseFailed(message) + } + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageSnapshot.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageSnapshot.swift new file mode 100644 index 0000000000..67df73bd19 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageSnapshot.swift @@ -0,0 +1,145 @@ +import Foundation + +public struct QwenCloudUsageSnapshot: Sendable { + public let planName: String? + public let usedQuota: Double? + public let totalQuota: Double? + public let remainingQuota: Double? + public let resetsAt: Date? + public let fiveHourUsedPercent: Double? + public let fiveHourTotalQuota: Double? + public let fiveHourResetsAt: Date? + public let weeklyUsedPercent: Double? + public let weeklyTotalQuota: Double? + public let weeklyResetsAt: Date? + public let updatedAt: Date + + public init( + planName: String?, + usedQuota: Double?, + totalQuota: Double?, + remainingQuota: Double?, + resetsAt: Date?, + fiveHourUsedPercent: Double? = nil, + fiveHourTotalQuota: Double? = nil, + fiveHourResetsAt: Date? = nil, + weeklyUsedPercent: Double? = nil, + weeklyTotalQuota: Double? = nil, + weeklyResetsAt: Date? = nil, + updatedAt: Date) + { + self.planName = planName + self.usedQuota = usedQuota + self.totalQuota = totalQuota + self.remainingQuota = remainingQuota + self.resetsAt = resetsAt + self.fiveHourUsedPercent = fiveHourUsedPercent + self.fiveHourTotalQuota = fiveHourTotalQuota + self.fiveHourResetsAt = fiveHourResetsAt + self.weeklyUsedPercent = weeklyUsedPercent + self.weeklyTotalQuota = weeklyTotalQuota + self.weeklyResetsAt = weeklyResetsAt + self.updatedAt = updatedAt + } +} + +extension QwenCloudUsageSnapshot { + init(alibabaSnapshot: AlibabaTokenPlanUsageSnapshot) { + self.init( + planName: alibabaSnapshot.planName, + usedQuota: alibabaSnapshot.usedQuota, + totalQuota: alibabaSnapshot.totalQuota, + remainingQuota: alibabaSnapshot.remainingQuota, + resetsAt: alibabaSnapshot.resetsAt, + updatedAt: alibabaSnapshot.updatedAt) + } + + public func toUsageSnapshot() -> UsageSnapshot { + let currentPrimary = self.fiveHourUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 5 * 60, + resetsAt: self.fiveHourResetsAt, + resetDescription: Self.quotaDetail(usedPercent: $0, total: self.fiveHourTotalQuota)) + } + let legacyPrimary = 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 = currentPrimary ?? legacyPrimary + let secondary = self.weeklyUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 7 * 24 * 60, + resetsAt: self.weeklyResetsAt, + resetDescription: Self.quotaDetail(usedPercent: $0, total: self.weeklyTotalQuota)) + } + + let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (planName?.isEmpty ?? true) ? nil : planName + let identity = ProviderIdentitySnapshot( + providerID: .qwencloud, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + 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 { + used + } else if let remaining { + total - remaining + } else { + nil + } + guard let usedValue else { return nil } + let normalizedUsed = max(0, min(usedValue, total)) + return normalizedUsed / total * 100 + } + + private static func quotaDetail(used: Double?, total: Double?, remaining: Double?) -> String? { + if let used, let total, total > 0 { + return "\(self.format(used)) / \(self.format(total)) credits used" + } + if let remaining, let total, total > 0 { + return "\(Self.format(remaining)) / \(Self.format(total)) credits left" + } + if let remaining { + return "\(Self.format(remaining)) credits left" + } + return nil + } + + private static func quotaDetail(usedPercent: Double, total: Double?) -> String? { + guard let total, total > 0 else { return nil } + let used = total * usedPercent / 100 + return "\(Self.format(used)) / \(Self.format(total)) credits used" + } + + private static func format(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = true + formatter.maximumFractionDigits = value.rounded() == value ? 0 : 2 + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleChromiumCookieFallbackImporter.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleChromiumCookieFallbackImporter.swift new file mode 100644 index 0000000000..0b06d9d740 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleChromiumCookieFallbackImporter.swift @@ -0,0 +1,321 @@ +import Foundation + +#if os(macOS) +import CommonCrypto +import Security +import SQLite3 +import SweetCookieKit + +enum AliyunOneConsoleChromiumCookieFallbackImporter { + private struct ChromiumCookieRecord { + let domain: String + let name: String + let path: String + let value: String + let expires: Date? + let isSecure: Bool + } + + enum ImportError: LocalizedError { + case keyUnavailable(browser: Browser) + case keychainDenied(browser: Browser) + case sqliteFailed(label: String, details: String) + + var errorDescription: String? { + switch self { + case let .keyUnavailable(browser): + "\(browser.displayName) Safe Storage key not found." + case let .keychainDenied(browser): + "macOS Keychain denied access to \(browser.displayName) Safe Storage." + case let .sqliteFailed(label, details): + "\(label) cookie fallback failed: \(details)" + } + } + } + + static func importSession( + browser: Browser, + domains: [String], + isAuthenticatedSession: ([HTTPCookie]) -> Bool, + sessionLabel: String, + cookieClient: BrowserCookieClient = BrowserCookieClient(), + logger: ((String) -> Void)? = nil) throws -> AliyunOneConsoleCookieImporter.SessionInfo? + { + let stores = try cookieClient.codexBarStores(for: browser).filter { $0.databaseURL != nil } + guard !stores.isEmpty else { return nil } + + logger?("Trying \(browser.displayName) Chromium fallback") + let keys = try self.derivedKeys(for: browser) + for store in stores { + let cookies = try self.loadCookies(from: store, domains: domains, keys: keys) + guard !cookies.isEmpty else { continue } + if isAuthenticatedSession(cookies) { + logger?("Found \(cookies.count) \(sessionLabel) cookies via \(store.label) fallback") + return AliyunOneConsoleCookieImporter.SessionInfo(cookies: cookies, sourceLabel: store.label) + } + } + return nil + } + + private static func loadCookies( + from store: BrowserCookieStore, + domains: [String], + keys: [Data]) throws -> [HTTPCookie] + { + guard let sourceDB = store.databaseURL else { return [] } + let records = try self.readCookiesFromLockedDB( + sourceDB: sourceDB, + domains: domains, + keys: keys, + label: store.label) + return records.compactMap(self.makeCookie) + } + + private static func readCookiesFromLockedDB( + sourceDB: URL, + domains: [String], + keys: [Data], + label: String) throws -> [ChromiumCookieRecord] + { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("aliyun-oneconsole-chromium-cookies-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + let copiedDB = tempDir.appendingPathComponent("Cookies") + try FileManager.default.copyItem(at: sourceDB, to: copiedDB) + for suffix in ["-wal", "-shm"] { + let src = URL(fileURLWithPath: sourceDB.path + suffix) + if FileManager.default.fileExists(atPath: src.path) { + let dst = URL(fileURLWithPath: copiedDB.path + suffix) + try? FileManager.default.copyItem(at: src, to: dst) + } + } + defer { try? FileManager.default.removeItem(at: tempDir) } + + return try self.readCookies(fromDB: copiedDB.path, domains: domains, keys: keys, label: label) + } + + private static func readCookies( + fromDB path: String, + domains: [String], + keys: [Data], + label: String) throws -> [ChromiumCookieRecord] + { + var db: OpaquePointer? + guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_close(db) } + + let sql = "SELECT host_key, name, path, expires_utc, is_secure, value, encrypted_value FROM cookies" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(stmt) } + + var records: [ChromiumCookieRecord] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let hostKey = self.readText(stmt, index: 0), self.matches(domain: hostKey, patterns: domains) else { + continue + } + guard let name = self.readText(stmt, index: 1), let path = self.readText(stmt, index: 2) else { + continue + } + + let value: String? = if let plain = self.readText(stmt, index: 5), !plain.isEmpty { + plain + } else if let encrypted = self.readBlob(stmt, index: 6) { + self.decrypt(encrypted, usingAnyOf: keys) + } else { + nil + } + guard let value, !value.isEmpty else { continue } + + records.append(ChromiumCookieRecord( + domain: AliyunOneConsoleCookieImporter.normalizeCookieDomain(hostKey), + name: name, + path: path, + value: value, + expires: self.chromiumExpiry(sqlite3_column_int64(stmt, 3)), + isSecure: sqlite3_column_int(stmt, 4) != 0)) + } + + return records.filter { record in + guard let expires = record.expires else { return true } + return expires >= Date() + } + } + + private static func derivedKeys(for browser: Browser) throws -> [Data] { + var keys: [Data] = [] + var sawDenied = false + + for label in browser.safeStorageLabels { + switch KeychainAccessPreflight.checkGenericPassword(service: label.service, account: label.account) { + case .interactionRequired: + sawDenied = true + continue + case .allowed, .notFound, .failure: + break + } + + if let password = self.safeStoragePassword(service: label.service, account: label.account) { + keys.append(self.deriveKey(from: password)) + } + } + + if !keys.isEmpty { + return keys + } + if sawDenied { + throw ImportError.keychainDenied(browser: browser) + } + throw ImportError.keyUnavailable(browser: browser) + } + + private static func safeStoragePassword(service: String, account: String) -> String? { + // The preflight classifies prompt-requiring items as .interactionRequired, but its + // .notFound (gate disabled) and .failure outcomes still reach this read. Honor the + // access gate and keep the read strictly non-interactive so it can never prompt. + guard !KeychainAccessGate.isDisabled else { return nil } + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + + var result: AnyObject? + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func deriveKey(from password: String) -> Data { + let salt = Data("saltysalt".utf8) + var key = Data(count: kCCKeySizeAES128) + let keyLength = key.count + _ = key.withUnsafeMutableBytes { keyBytes in + password.utf8CString.withUnsafeBytes { passBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passBytes.bindMemory(to: Int8.self).baseAddress, + passBytes.count - 1, + saltBytes.bindMemory(to: UInt8.self).baseAddress, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + keyBytes.bindMemory(to: UInt8.self).baseAddress, + keyLength) + } + } + } + return key + } + + private static func decrypt(_ encryptedValue: Data, usingAnyOf keys: [Data]) -> String? { + for key in keys { + if let value = self.decrypt(encryptedValue, key: key) { + return value + } + } + return nil + } + + private static func decrypt(_ encryptedValue: Data, key: Data) -> String? { + guard encryptedValue.count > 3 else { return nil } + let prefix = String(data: encryptedValue.prefix(3), encoding: .utf8) + guard prefix == "v10" else { return nil } + + let payload = Data(encryptedValue.dropFirst(3)) + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + var outLength = 0 + var out = Data(count: payload.count + kCCBlockSizeAES128) + let outCapacity = out.count + + let status = out.withUnsafeMutableBytes { outBytes in + payload.withUnsafeBytes { payloadBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + payloadBytes.baseAddress, + payload.count, + outBytes.baseAddress, + outCapacity, + &outLength) + } + } + } + } + + guard status == kCCSuccess else { return nil } + out.count = outLength + + if let value = String(data: out, encoding: .utf8), !value.isEmpty { + return value + } + if out.count > 32 { + let trimmed = out.dropFirst(32) + if let value = String(data: trimmed, encoding: .utf8), !value.isEmpty { + return value + } + } + return nil + } + + private static func makeCookie(from record: ChromiumCookieRecord) -> HTTPCookie? { + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: record.domain, + .path: record.path, + .name: record.name, + .value: record.value, + ] + if record.isSecure { + properties[.secure] = true + } + if let expires = record.expires { + properties[.expires] = expires + } + return HTTPCookie(properties: properties) + } + + private static func readText(_ stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let value = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: value) + } + + private static func readBlob(_ stmt: OpaquePointer?, index: Int32) -> Data? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let bytes = sqlite3_column_blob(stmt, index) + else { + return nil + } + return Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) + } + + private static func matches(domain: String, patterns: [String]) -> Bool { + AliyunOneConsoleCookieImporter.matchesCookieDomain(domain, patterns: patterns) + } + + private static func chromiumExpiry(_ expiresUTC: Int64) -> Date? { + guard expiresUTC > 0 else { return nil } + let seconds = (Double(expiresUTC) / 1_000_000.0) - 11_644_473_600.0 + guard seconds > 0 else { return nil } + return Date(timeIntervalSince1970: seconds) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieHeaders.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieHeaders.swift new file mode 100644 index 0000000000..79449708be --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieHeaders.swift @@ -0,0 +1,155 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Generic cookie-header pair for Aliyun OneConsole-based providers. +/// +/// Each provider keeps one HTTPCookie jar but may need to send a slightly +/// different Cookie header on dashboard GETs versus API POSTs (different +/// host scopes, different CSRF / sec_token presence). This struct holds +/// both headers together and handles the cached-header round-trip so +/// providers don't reinvent that plumbing. +public struct OneConsoleCookieHeaders: Sendable { + public let apiCookieHeader: String + public let dashboardCookieHeader: String + + public init(apiCookieHeader: String, dashboardCookieHeader: String) { + self.apiCookieHeader = apiCookieHeader + self.dashboardCookieHeader = dashboardCookieHeader + } + + public init?(singleHeader raw: String?) { + guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } + self.apiCookieHeader = normalized + self.dashboardCookieHeader = normalized + } + + public init?(cachedHeader raw: String?, cacheNamespace: String) { + var valuesByName: [String: String] = [:] + for pair in CookieHeaderNormalizer.pairs(from: raw ?? "") { + valuesByName[pair.name] = pair.value + } + if let encodedAPI = valuesByName["__codexbar_\(cacheNamespace)_api"], + let encodedDashboard = valuesByName["__codexbar_\(cacheNamespace)_dashboard"], + let apiHeader = Self.decodeCachedHeader(encodedAPI), + let dashboardHeader = Self.decodeCachedHeader(encodedDashboard), + let normalizedAPI = CookieHeaderNormalizer.normalize(apiHeader), + let normalizedDashboard = CookieHeaderNormalizer.normalize(dashboardHeader) + { + self.init(apiCookieHeader: normalizedAPI, dashboardCookieHeader: normalizedDashboard) + return + } + + self.init(singleHeader: raw) + } + + public func cacheCookieHeader(namespace: String) -> String { + [ + "__codexbar_\(namespace)_api=\(Self.encodeCachedHeader(self.apiCookieHeader))", + "__codexbar_\(namespace)_dashboard=\(Self.encodeCachedHeader(self.dashboardCookieHeader))", + ].joined(separator: "; ") + } + + public var apiCookieNames: [String] { + Self.cookieNames(from: self.apiCookieHeader) + } + + public var dashboardCookieNames: [String] { + Self.cookieNames(from: self.dashboardCookieHeader) + } + + public func hasCookie(named name: String) -> Bool { + Self.cookieNames(from: self.apiCookieHeader).contains(name) || + Self.cookieNames(from: self.dashboardCookieHeader).contains(name) + } + + private static func cookieNames(from header: String) -> [String] { + CookieHeaderNormalizer.pairs(from: header) + .map(\.name) + .filter { !$0.isEmpty } + .uniquedSorted() + } + + private static func encodeCachedHeader(_ header: String) -> String { + Data(header.utf8).base64EncodedString() + } + + private static func decodeCachedHeader(_ encoded: String) -> String? { + guard let data = Data(base64Encoded: encoded) else { return nil } + return String(data: data, encoding: .utf8) + } +} + +/// Builds a Cookie header from an [HTTPCookie] jar scoped to a target URL. +public enum OneConsoleCookieHeaderBuilder { + /// Returns a single Cookie header string that includes every cookie in + /// `cookies` whose domain and path match `targetURL`, choosing the most + /// specific cookie (longest path, longest domain, latest expiry) when + /// duplicates exist. + public static func header(from cookies: [HTTPCookie], targetURL: 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 } + + if let existing = byName[cookie.name] { + if Self.cookieSortKey(for: cookie) >= Self.cookieSortKey(for: existing) { + byName[cookie.name] = cookie + } + } else { + byName[cookie.name] = cookie + } + } + + guard !byName.isEmpty else { return nil } + return byName.keys.sorted().compactMap { name in + guard let cookie = byName[name] else { return nil } + return "\(cookie.name)=\(cookie.value)" + }.joined(separator: "; ") + } + + private static func matchesRequestURL(cookie: HTTPCookie, url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + guard !normalizedDomain.isEmpty else { return false } + guard host == normalizedDomain || host.hasSuffix(".\(normalizedDomain)") else { return false } + + let cookiePath = cookie.path.isEmpty ? "/" : cookie.path + let requestPath = url.path.isEmpty ? "/" : url.path + if requestPath == cookiePath { + return true + } + guard requestPath.hasPrefix(cookiePath) else { return false } + guard cookiePath != "/" else { return true } + if cookiePath.hasSuffix("/") { + return true + } + guard + let boundaryIndex = requestPath.index( + requestPath.startIndex, + offsetBy: cookiePath.count, + limitedBy: requestPath.endIndex), + boundaryIndex < requestPath.endIndex + else { + return true + } + return requestPath[boundaryIndex] == "/" + } + + private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) { + let pathLength = cookie.path.count + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let domainLength = normalizedDomain.count + let expiry = cookie.expiresDate ?? .distantPast + return (pathLength, domainLength, expiry) + } +} + +extension [String] { + func uniquedSorted() -> [String] { + Array(Set(self)).sorted() + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieImporter.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieImporter.swift new file mode 100644 index 0000000000..ac35e4d493 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieImporter.swift @@ -0,0 +1,215 @@ +import Foundation + +public struct AliyunOneConsoleCookieImportError: LocalizedError, Sendable { + public let details: String? + + public init(details: String? = nil) { + self.details = details + } + + public var errorDescription: String? { + self.details + } +} + +#if os(macOS) +import SweetCookieKit + +/// Generic browser-cookie importer for Aliyun OneConsole-based providers. +/// +/// Each provider (Alibaba Coding Plan, Alibaba Token Plan, Qwen Cloud, ...) declares +/// its own cookie domains and "is this an authenticated session" predicate. Everything +/// else -- the per-browser iteration, Chromium fallback, Keychain preflight, and +/// diagnostic collection -- is shared here. +public enum AliyunOneConsoleCookieImporter { + private static let cookieClient = BrowserCookieClient() + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + var byName: [String: HTTPCookie] = [:] + byName.reserveCapacity(self.cookies.count) + + for cookie in self.cookies { + if let expiry = cookie.expiresDate, expiry < Date() { + continue + } + guard !cookie.value.isEmpty else { continue } + if let existing = byName[cookie.name] { + let existingExpiry = existing.expiresDate ?? .distantPast + let candidateExpiry = cookie.expiresDate ?? .distantPast + if candidateExpiry >= existingExpiry { + byName[cookie.name] = cookie + } + } else { + byName[cookie.name] = cookie + } + } + + return byName.keys.sorted().compactMap { name in + guard let cookie = byName[name] else { return nil } + return "\(cookie.name)=\(cookie.value)" + }.joined(separator: "; ") + } + } + + /// Generic browser-cookie import for Aliyun OneConsole-based providers. + /// The domain list, session-validation rules, and browser-import order are + /// provider-specific; everything else is shared. + public static func importSession( + browserDetection: BrowserDetection, + domains: [String], + isAuthenticatedSession: @escaping ([HTTPCookie]) -> Bool, + logPrefix: String, + sessionLabel: String, + importOrder: BrowserCookieImportOrder = Browser.defaultImportOrder, + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[\(logPrefix)] \(msg)") } + var accessDeniedHints: [String] = [] + var failureDetails: [String] = [] + let installedBrowsers = self.cookieImportCandidates( + browserDetection: browserDetection, + importOrder: importOrder) + log("Cookie import candidates: \(installedBrowsers.map(\.displayName).joined(separator: ", "))") + + for browserSource in installedBrowsers { + do { + log("Checking \(browserSource.displayName)") + let query = BrowserCookieQuery(domains: domains) + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + if sources.isEmpty { + log("No matching cookie records in \(browserSource.displayName)") + if let fallbackSession = try Self.importChromiumFallbackSession( + browser: browserSource, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + sessionLabel: sessionLabel, + logger: log) + { + return fallbackSession + } + } + for source in sources where !source.records.isEmpty { + let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + if isAuthenticatedSession(httpCookies) { + log("Found \(httpCookies.count) \(sessionLabel) cookies in \(source.label)") + return SessionInfo(cookies: httpCookies, sourceLabel: source.label) + } + log( + "Skipping \(source.label): missing auth cookies" + + " (\(httpCookies.count) cookies)") + } + if let fallbackSession = try Self.importChromiumFallbackSession( + browser: browserSource, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + sessionLabel: sessionLabel, + logger: log) + { + return fallbackSession + } + } catch let error as BrowserCookieError { + BrowserCookieAccessGate.recordIfNeeded(error) + if let hint = error.accessDeniedHint { + accessDeniedHints.append(hint) + } + failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } catch { + failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + let details = (Array(Set(accessDeniedHints)).sorted() + Array(Set(failureDetails)).sorted()) + .joined(separator: " ") + throw AliyunOneConsoleCookieImportError(details: details.isEmpty ? nil : details) + } + + public static func hasSession( + browserDetection: BrowserDetection, + domains: [String], + isAuthenticatedSession: @escaping ([HTTPCookie]) -> Bool, + logPrefix: String, + sessionLabel: String, + importOrder: BrowserCookieImportOrder = Browser.defaultImportOrder, + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession( + browserDetection: browserDetection, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + logPrefix: logPrefix, + sessionLabel: sessionLabel, + importOrder: importOrder, + logger: logger) + return true + } catch { + return false + } + } + + private static func importChromiumFallbackSession( + browser: Browser, + domains: [String], + isAuthenticatedSession: @escaping ([HTTPCookie]) -> Bool, + sessionLabel: String, + logger: ((String) -> Void)? = nil) throws -> SessionInfo? + { + guard browser.usesChromiumProfileStore else { return nil } + guard let fallbackSession = try AliyunOneConsoleChromiumCookieFallbackImporter.importSession( + browser: browser, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + sessionLabel: sessionLabel, + logger: logger) + else { + return nil + } + guard isAuthenticatedSession(fallbackSession.cookies) else { + logger?( + "Fallback cookies missing auth cookies" + + " (\(fallbackSession.cookies.count) cookies); ignoring" + + " \(fallbackSession.sourceLabel)") + return nil + } + logger?( + "Using fallback import (\(fallbackSession.cookies.count) \(sessionLabel) cookies)" + + " from \(fallbackSession.sourceLabel)") + return fallbackSession + } + + public static func cookieImportCandidates( + browserDetection: BrowserDetection, + importOrder: BrowserCookieImportOrder) -> [Browser] + { + importOrder.cookieImportCandidates(using: browserDetection) + } + + public static func matchesCookieDomain(_ domain: String, patterns: [String]) -> Bool { + let normalized = self.normalizeCookieDomain(domain) + return patterns.contains { pattern in + let normalizedPattern = self.normalizeCookieDomain(pattern) + return normalized == normalizedPattern || normalized.hasSuffix(".\(normalizedPattern)") + } + } + + public static func normalizeCookieDomain(_ domain: String) -> String { + let trimmed = domain.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.hasPrefix(".") ? String(trimmed.dropFirst()) : trimmed + return normalized.lowercased() + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleCookieRouting.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleCookieRouting.swift new file mode 100644 index 0000000000..e3cd432e04 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleCookieRouting.swift @@ -0,0 +1,123 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Redirect policy for Aliyun OneConsole sessions with distinct dashboard and API cookie scopes. +/// +/// Trusted dashboard/API redirects receive the matching cookie header. Cross-origin +/// redirects are followed only as credential-free GET/HEAD navigations; body-bearing +/// redirects are rejected so a 307/308 cannot forward `sec_token` or request parameters. +public struct OneConsoleCookieRouting: Sendable { + private static let redirectStatusCodes: Set = [301, 302, 303, 307, 308] + + public let apiURL: URL + public let dashboardURL: URL + public let apiCookieHeader: String + public let dashboardCookieHeader: String + + public init( + apiURL: URL, + dashboardURL: URL, + apiCookieHeader: String, + dashboardCookieHeader: String) + { + self.apiURL = apiURL + self.dashboardURL = dashboardURL + self.apiCookieHeader = apiCookieHeader + self.dashboardCookieHeader = dashboardCookieHeader + } + + public func redirectedRequest( + forRedirectFrom original: URLRequest, + response: HTTPURLResponse, + to redirected: URLRequest) -> URLRequest? + { + guard Self.redirectStatusCodes.contains(response.statusCode) else { return nil } + guard let originalURL = original.url, + originalURL.scheme?.caseInsensitiveCompare("https") == .orderedSame, + originalURL.user == nil, + originalURL.password == nil + else { + return nil + } + let sourceURL = response.url ?? originalURL + guard sourceURL.scheme?.caseInsensitiveCompare("https") == .orderedSame, + sourceURL.user == nil, + sourceURL.password == nil + else { + return nil + } + guard let redirectedURL = redirected.url, + redirectedURL.scheme?.caseInsensitiveCompare("https") == .orderedSame, + redirectedURL.host != nil, + redirectedURL.user == nil, + redirectedURL.password == nil + else { + return nil + } + + let isCrossOrigin = !Self.isSameOrigin(sourceURL, redirectedURL) + if isCrossOrigin, !Self.isCredentialFreeNavigation(redirected) { + return nil + } + + let acceptsHTML = original.value(forHTTPHeaderField: "Accept")?.contains("text/html") == true + var routed: URLRequest + if isCrossOrigin { + guard let sanitized = Self.sanitizedNavigation(from: redirected) else { return nil } + routed = sanitized + } else { + routed = redirected + } + if Self.isSameOrigin(redirectedURL, self.apiURL), + redirectedURL.path == self.apiURL.path, + !acceptsHTML + { + routed.setValue(self.apiCookieHeader, forHTTPHeaderField: "Cookie") + return routed + } + if Self.isSameOrigin(redirectedURL, self.dashboardURL) { + routed.setValue(self.dashboardCookieHeader, forHTTPHeaderField: "Cookie") + return routed + } + + return Self.sanitizedNavigation(from: routed) + } + + private static func isCredentialFreeNavigation(_ request: URLRequest) -> Bool { + let method = request.httpMethod?.uppercased() ?? "GET" + guard method == "GET" || method == "HEAD" else { return false } + return request.httpBody == nil && request.httpBodyStream == nil + } + + private static func sanitizedNavigation(from request: URLRequest) -> URLRequest? { + guard let url = request.url, self.isCredentialFreeNavigation(request) else { return nil } + var sanitized = URLRequest( + url: url, + cachePolicy: request.cachePolicy, + timeoutInterval: request.timeoutInterval) + sanitized.httpMethod = request.httpMethod?.uppercased() ?? "GET" + for header in ["Accept", "Accept-Language", "User-Agent"] { + if let value = request.value(forHTTPHeaderField: header) { + sanitized.setValue(value, forHTTPHeaderField: header) + } + } + return sanitized + } + + private static func isSameOrigin(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.scheme?.lowercased() == rhs.scheme?.lowercased() && + lhs.host?.lowercased() == rhs.host?.lowercased() && + self.normalizedPort(lhs) == self.normalizedPort(rhs) + } + + private static func normalizedPort(_ url: URL) -> Int? { + if let port = url.port { return port } + switch url.scheme?.lowercased() { + case "http": return 80 + case "https": return 443 + default: return nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleJSON.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleJSON.swift new file mode 100644 index 0000000000..7a45602f1b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleJSON.swift @@ -0,0 +1,223 @@ +import Foundation + +/// Recursive JSON traversal + scalar coercion helpers shared across Aliyun +/// OneConsole-based providers. The gateway responses nest subscription +/// metadata inside double-stringified JSON envelopes; these helpers walk +/// the tree without committing to a particular payload schema. +public enum OneConsoleJSON { + /// Recursively expands any string value that itself parses as JSON, + /// so consumers can treat `{"data": "{\"foo\": 1}"}` as if it were + /// `{"data": {"foo": 1}}`. Non-JSON strings and primitives pass through. + public static func expandEmbeddedJSON(_ value: Any) -> Any { + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else { return value } + guard let data = trimmed.data(using: .utf8), + let decoded = try? JSONSerialization.jsonObject(with: data) + else { + return value + } + return self.expandEmbeddedJSON(decoded) + } + if let dictionary = value as? [String: Any] { + return dictionary.mapValues(self.expandEmbeddedJSON) + } + if let array = value as? [Any] { + return array.map(self.expandEmbeddedJSON) + } + return value + } + + /// Returns the first dictionary anywhere in `value` that contains any + /// of the given keys. Useful for "find the quota object regardless of + /// where the API nested it" parsers. + public static func findObject( + containingAnyOf keys: Set, + in value: Any) -> [String: Any]? + { + if let dictionary = value as? [String: Any] { + if !keys.isDisjoint(with: dictionary.keys) { + return dictionary + } + for nested in dictionary.values { + if let found = self.findObject(containingAnyOf: keys, in: nested) { + return found + } + } + } else if let array = value as? [Any] { + for nested in array { + if let found = self.findObject(containingAnyOf: keys, in: nested) { + return found + } + } + } + return nil + } + + /// Returns the first value associated with any of `keys` anywhere in `value`. + public static func findFirstValue(forKeys keys: [String], in value: Any) -> Any? { + let lowercasedKeys = Set(keys.map { $0.lowercased() }) + if let dictionary = value as? [String: Any] { + for (key, nested) in dictionary where lowercasedKeys.contains(key.lowercased()) { + return nested + } + for nested in dictionary.values { + if let found = self.findFirstValue(forKeys: keys, in: nested) { + return found + } + } + } else if let array = value as? [Any] { + for nested in array { + if let found = self.findFirstValue(forKeys: keys, in: nested) { + return found + } + } + } + return nil + } + + /// Returns the first string value associated with any of `keys` in `value`. + public static func findFirstString(forKeys keys: [String], in value: Any) -> String? { + for key in keys { + if let found = self.findFirstConvertedValue( + forKey: key, + in: value, + transform: self.string) + { + return found + } + } + return nil + } + + /// Returns the first integer value associated with any of `keys` in `value`. + public static func findFirstInt(forKeys keys: [String], in value: Any) -> Int? { + for key in keys { + if let found = self.findFirstConvertedValue( + forKey: key, + in: value, + transform: self.int) + { + return found + } + } + return nil + } + + /// Returns the first array value associated with any of `keys` in `value`. + public static func findFirstArray(forKeys keys: [String], in value: Any) -> [Any]? { + for key in keys { + if let found = self.findFirstConvertedValue( + forKey: key, + in: value, + transform: { $0 as? [Any] }) + { + return found + } + } + return nil + } + + /// Searches one key at a time so caller priority is preserved across the full tree. + /// Invalid values do not mask a later valid value for the same key. + private static func findFirstConvertedValue( + forKey expectedKey: String, + in value: Any, + transform: (Any?) -> T?) -> T? + { + if let dictionary = value as? [String: Any] { + for (key, nested) in dictionary where key.caseInsensitiveCompare(expectedKey) == .orderedSame { + if let converted = transform(nested) { + return converted + } + } + for nested in dictionary.values { + if let found = self.findFirstConvertedValue( + forKey: expectedKey, + in: nested, + transform: transform) + { + return found + } + } + } else if let array = value as? [Any] { + for nested in array { + if let found = self.findFirstConvertedValue( + forKey: expectedKey, + in: nested, + transform: transform) + { + return found + } + } + } + return nil + } + + /// Coerces `value` to Double. Accepts NSNumber, Int, Double, and numeric + /// strings. Returns nil if the value is non-numeric. + public static func number(_ value: Any?) -> Double? { + guard let value else { return nil } + if let number = value as? NSNumber { + return number.doubleValue + } + if let string = value as? String { + return Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// Coerces `value` to Int. Accepts NSNumber, Int, Int64, Double (truncated), + /// and numeric strings. + public static func int(_ value: Any?) -> Int? { + guard let value else { return nil } + if let intValue = value as? Int { return intValue } + if let int64Value = value as? Int64 { return Int(int64Value) } + if let number = value as? NSNumber { return number.intValue } + if let doubleValue = value as? Double { return Int(doubleValue) } + if let string = value as? String { + return Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// Coerces `value` to String after trimming whitespace. Returns nil for + /// empty or non-string values. + public static func string(_ value: Any?) -> String? { + guard let value = value as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Converts a 0..1 ratio into a 0..100 percentage, clamping to that range. + /// Returns nil for non-finite ratios. + public static func percentagePoints(fromRatio ratio: Double?) -> Double? { + guard let ratio, ratio.isFinite else { return nil } + return min(max(ratio, 0), 1) * 100 + } + + /// Coerces `value` to a Date. Accepts: + /// - Positive numeric epoch in seconds or milliseconds (auto-detected via magnitude) + /// - ISO 8601 strings + /// - "yyyy-MM-dd", "yyyy-MM-dd HH:mm", and "yyyy-MM-dd HH:mm:ss" strings + public static func date(_ value: Any?) -> Date? { + if let number = self.number(value), number > 0 { + let seconds = number >= 1_000_000_000_000 ? number / 1000 : number + return Date(timeIntervalSince1970: seconds) + } + guard let string = value as? String else { return nil } + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: string) { + return date + } + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + for format in ["yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"] { + dateFormatter.dateFormat = format + if let date = dateFormatter.date(from: string) { + return date + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleSECTokenResolver.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleSECTokenResolver.swift new file mode 100644 index 0000000000..ad685272c6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleSECTokenResolver.swift @@ -0,0 +1,233 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Resolves the Aliyun OneConsole `sec_token` for a given authenticated +/// session. The token is required for every gateway API call; it lives +/// either in the dashboard HTML (as an inline JS constant), on the user-info +/// endpoint, or as a cookie scoped to the console host. Providers configure +/// which sources to probe and how to recognize their login page. +public struct OneConsoleSECTokenResolver: Sendable { + public struct Configuration: Sendable { + /// Resolves the dashboard URL for the current environment (host override aware). + public let dashboardURL: @Sendable ([String: String]) -> URL + /// Path under the dashboard host to the user-info JSON endpoint (typically "tool/user/info.json"). + public let userInfoPath: String + /// Provider-specific login-page detector. OneConsole providers use + /// different passport hosts and login markup, so the provider owns this + /// classification instead of flattening alternatives into shared rules. + public let isLoginPage: @Sendable (String) -> Bool + /// Additional regex patterns the provider wants to try beyond the + /// built-in set (secToken, sec_token, csrfToken). + public let extraHTMLPatterns: [String] + + public init( + dashboardURL: @escaping @Sendable ([String: String]) -> URL, + userInfoPath: String, + isLoginPage: @escaping @Sendable (String) -> Bool, + extraHTMLPatterns: [String] = []) + { + self.dashboardURL = dashboardURL + self.userInfoPath = userInfoPath + self.isLoginPage = isLoginPage + self.extraHTMLPatterns = extraHTMLPatterns + } + } + + public enum Source: String, Sendable { + case dashboardHTML = "dashboard-html" + case cookie + case userInfo = "user-info" + } + + public struct Resolved: Sendable, Equatable { + public let value: String + public let source: Source + } + + public let configuration: Configuration + + public init(configuration: Configuration) { + self.configuration = configuration + } + + public func resolve( + cookieHeader: String, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> Resolved + { + let dashboardURL = self.configuration.dashboardURL(environment) + + // 1. Try the dashboard HTML. The one-console injects sec_token as an + // inline JS constant; it's the freshest source. + var dashboardFailure: Error? + do { + let token = try await self.fetchFromDashboard( + cookieHeader: cookieHeader, + dashboardURL: dashboardURL, + transport: transport) + return Resolved(value: token, source: .dashboardHTML) + } catch OneConsoleSECTokenError.notFound { + // Continue through the token fallbacks. + } catch { + dashboardFailure = error + } + + // 2. Fall back to a sec_token cookie scoped to the console host. + if let cookieToken = Self.secTokenCookieValue(from: cookieHeader, host: dashboardURL.host) { + return Resolved(value: cookieToken, source: .cookie) + } + + // 3. Final fallback: the user-info JSON endpoint. + do { + let userInfoToken = try await self.fetchFromUserInfo( + cookieHeader: cookieHeader, + dashboardURL: dashboardURL, + transport: transport) + return Resolved(value: userInfoToken, source: .userInfo) + } catch OneConsoleSECTokenError.notFound { + // A retained dashboard failure is more accurate than missing credentials. + } catch { + throw error + } + + if let dashboardFailure { + throw dashboardFailure + } + + throw OneConsoleSECTokenError.notFound + } + + // MARK: - Dashboard HTML + + private func fetchFromDashboard( + cookieHeader: String, + dashboardURL: URL, + transport: any ProviderHTTPTransport) async throws -> String + { + var request = URLRequest(url: dashboardURL) + request.httpMethod = "GET" + request.timeoutInterval = 20 + request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let (data, response) = try await transport.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard http.statusCode == 200 else { + if (500...599).contains(http.statusCode) { + throw URLError(.badServerResponse) + } + throw OneConsoleSECTokenError.notFound + } + guard let html = String(data: data, encoding: .utf8) else { + throw OneConsoleSECTokenError.notFound + } + if self.configuration.isLoginPage(html) { + throw OneConsoleSECTokenError.notFound + } + if let token = Self.extractToken(from: html, extraPatterns: self.configuration.extraHTMLPatterns) { + return token + } + throw OneConsoleSECTokenError.notFound + } + + private static func extractToken(from html: String, extraPatterns: [String]) -> String? { + let patterns = [ + #""secToken"\s*:\s*"([^"]+)""#, + #""sec_token"\s*:\s*"([^"]+)""#, + #"secToken['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + #"sec_token['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + #"csrfToken['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + ] + extraPatterns + for pattern in patterns { + if let token = Self.firstMatchGroup(pattern: pattern, in: html), !token.isEmpty { + return token + } + } + return nil + } + + private static func firstMatchGroup(pattern: String, in text: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return nil + } + let range = NSRange(text.startIndex.. 1, + let valueRange = Range(match.range(at: 1), in: text) + else { + return nil + } + let value = text[valueRange].trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : String(value) + } + + // MARK: - Cookie fallback + + private static func secTokenCookieValue(from cookieHeader: String, host: String?) -> String? { + var fallback: String? + for pair in CookieHeaderNormalizer.pairs(from: cookieHeader) { + guard pair.name.lowercased() == "sec_token", !pair.value.isEmpty else { continue } + if let host, pair.value.contains(host) { + return pair.value + } + fallback = pair.value + } + return fallback + } + + // MARK: - User-info fallback + + private func fetchFromUserInfo( + cookieHeader: String, + dashboardURL: URL, + transport: any ProviderHTTPTransport) async throws -> String + { + var components = URLComponents() + components.scheme = dashboardURL.scheme ?? "https" + components.host = dashboardURL.host ?? "home.qwencloud.com" + if let port = dashboardURL.port { + components.port = port + } + components.path = self.configuration.userInfoPath + guard let url = components.url else { + throw OneConsoleSECTokenError.notFound + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 20 + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let (data, response) = try await transport.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw OneConsoleSECTokenError.notFound + } + guard let json = try? JSONSerialization.jsonObject(with: data) else { + throw OneConsoleSECTokenError.notFound + } + let expanded = OneConsoleJSON.expandEmbeddedJSON(json) + if let token = OneConsoleJSON.findFirstString( + forKeys: ["secToken", "sec_token", "csrfToken", "token"], + in: expanded) + { + return token + } + throw OneConsoleSECTokenError.notFound + } +} + +public enum OneConsoleSECTokenError: LocalizedError, Sendable { + case notFound + + public var errorDescription: String? { + switch self { + case .notFound: + "sec_token not found in dashboard, cookies, or user-info" + } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 7ff583f33d..08b9651994 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -825,7 +825,7 @@ enum CostUsageScanner { options: filtered, checkCancellation: checkCancellation) case .openai, .azureopenai, .clinepass, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, - .alibabatokenplan, .factory, + .alibabatokenplan, .qwencloud, .factory, .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .moonshot, .augment, .jetbrains, .amp, .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepseek, .deepinfra, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 6f25cd218d..55002eef26 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -9,6 +9,7 @@ enum ProviderChoice: String, AppEnum { case gemini case alibaba case alibabatokenplan + case qwencloud case antigravity case cursor case zai @@ -29,6 +30,7 @@ enum ProviderChoice: String, AppEnum { .gemini: DisplayRepresentation(title: "Gemini"), .alibaba: DisplayRepresentation(title: "Alibaba"), .alibabatokenplan: DisplayRepresentation(title: "Alibaba Token Plan"), + .qwencloud: DisplayRepresentation(title: "Qwen Cloud"), .antigravity: DisplayRepresentation(title: "Antigravity"), .cursor: DisplayRepresentation(title: "Cursor"), .zai: DisplayRepresentation(title: "z.ai"), @@ -49,6 +51,7 @@ enum ProviderChoice: String, AppEnum { case .gemini: .gemini case .alibaba: .alibaba case .alibabatokenplan: .alibabatokenplan + case .qwencloud: .qwencloud case .antigravity: .antigravity case .cursor: .cursor case .zai: .zai @@ -74,6 +77,7 @@ enum ProviderChoice: String, AppEnum { case .gemini: self = .gemini case .alibaba: self = .alibaba case .alibabatokenplan: self = .alibabatokenplan + case .qwencloud: self = .qwencloud case .antigravity: self = .antigravity case .cursor: self = .cursor case .opencode: self = .opencode diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 51e22c34d0..5a67e9dc73 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -313,6 +313,7 @@ private struct ProviderSwitchChip: View { case .opencodego: "OpenCode Go" case .alibaba: "Alibaba" case .alibabatokenplan: "Token Plan" + case .qwencloud: "Qwen Cloud" case .zai: "z.ai" case .factory: "Droid" case .copilot: "Copilot" @@ -1018,6 +1019,8 @@ enum WidgetColors { Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) case .alibaba, .alibabatokenplan: Color(red: 1.0, green: 106 / 255, blue: 0) + case .qwencloud: + Color(red: 97 / 255, green: 92 / 255, blue: 237 / 255) case .zai: Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255) case .factory: diff --git a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift index 494841db95..d49e9a93a9 100644 --- a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift +++ b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift @@ -53,9 +53,11 @@ struct AlibabaCodingPlanCookieImporterTests { return .allowed } operation: { #expect(throws: BrowserCookieStoreAccessSuppressedError.self) { - _ = try AlibabaChromiumCookieFallbackImporter.importSession( + _ = try AliyunOneConsoleChromiumCookieFallbackImporter.importSession( browser: .chrome, - domains: ["example.com"]) + domains: ["example.com"], + isAuthenticatedSession: { _ in false }, + sessionLabel: "Test") } } } diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 1db85ef8a0..c08bca81eb 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -115,7 +115,8 @@ struct AlibabaTokenPlanCookieHeaderTests { apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") - let cached = try #require(AlibabaTokenPlanCookieHeaders(cachedHeader: headers.cacheCookieHeader)) + let cached = try #require( + AlibabaTokenPlanCookieHeaders(alibabaTokenPlanCachedHeader: headers.cacheAlibabaTokenPlanCookieHeader())) #expect(cached.apiCookieHeader.contains("api_only=api")) #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index d1ded39685..3e22667461 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -457,6 +457,30 @@ final class CLIEntryTests: XCTestCase { environment: ["MIMO_LOCAL_USAGE_PATH": directory.appendingPathComponent("missing.json").path])) } + func test_sourceModeRequiresWebSupportAllowsQwenCookiesOnLinuxGate() { + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .qwencloud, + environment: ["QWEN_CLOUD_COOKIE": "login_qwencloud_ticket=test"])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .qwencloud, + settings: ProviderSettingsSnapshot.make( + qwenCloud: .init( + cookieSource: .manual, + manualCookieHeader: "login_qwencloud_ticket=test")))) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .qwencloud, + environment: [:])) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .qwencloud, + environment: ["QWEN_CLOUD_COOKIE": "login_qwencloud_ticket=test"], + settings: ProviderSettingsSnapshot.make( + qwenCloud: .init(cookieSource: .off, manualCookieHeader: nil)))) + } + private func assertKimiCodeCredentialSourceMode(in directory: URL) throws { let home = directory.appendingPathComponent("kimi-code", isDirectory: true) let credentials = home.appendingPathComponent("credentials", isDirectory: true) diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/flat_subscription_summary.json b/Tests/CodexBarTests/Fixtures/QwenCloud/flat_subscription_summary.json new file mode 100644 index 0000000000..c771170b5f --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/flat_subscription_summary.json @@ -0,0 +1,8 @@ +{ + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 2000, + "TotalSurplusValue": 1500 + } +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/forbidden.json b/Tests/CodexBarTests/Fixtures/QwenCloud/forbidden.json new file mode 100644 index 0000000000..61fe920e00 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/forbidden.json @@ -0,0 +1,4 @@ +{ + "statusCode": 403, + "message": "Forbidden" +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/login_required.json b/Tests/CodexBarTests/Fixtures/QwenCloud/login_required.json new file mode 100644 index 0000000000..41c6363392 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/login_required.json @@ -0,0 +1,5 @@ +{ + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "successResponse": false +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/nested_equity_list.json b/Tests/CodexBarTests/Fixtures/QwenCloud/nested_equity_list.json new file mode 100644 index 0000000000..cdf0d48954 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/nested_equity_list.json @@ -0,0 +1,21 @@ +{ + "code": "200", + "successResponse": true, + "data": { + "TotalCount": 1, + "Data": [ + { + "InstanceCode": "qwen-token-plan", + "Status": "NORMAL", + "EndTime": 1.701e12, + "EquityList": [ + { + "Type": "CREDITS", + "CycleTotalValue": "1000", + "CycleSurplusValue": "875" + } + ] + } + ] + } +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/no_active_subscription.json b/Tests/CodexBarTests/Fixtures/QwenCloud/no_active_subscription.json new file mode 100644 index 0000000000..1708cf20da --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/no_active_subscription.json @@ -0,0 +1,24 @@ +{ + "requestId": "00000000-0000-4000-8000-000000000001", + "code": "200", + "message": null, + "action": null, + "apiName": null, + "data": { + "RequestId": "00000000-0000-4000-8000-000000000001", + "Message": "Successful!", + "Data": { + "Uid": 7, + "TotalSurplusValue": "0", + "TotalCount": 0, + "TotalValue": "0", + "ProductCode": "sfm_tokenplansolo_public_intl" + }, + "Code": "Success", + "Success": true + }, + "httpStatusCode": "200", + "accessDeniedDetail": null, + "extendedCode": null, + "successResponse": true +} diff --git a/Tests/CodexBarTests/OneConsoleJSONTests.swift b/Tests/CodexBarTests/OneConsoleJSONTests.swift new file mode 100644 index 0000000000..008e9b7e4f --- /dev/null +++ b/Tests/CodexBarTests/OneConsoleJSONTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OneConsoleJSONTests { + @Test + func `string lookup honors caller key priority across the full tree`() { + let value: [String: Any] = [ + "token": "generic-token", + "data": [ + "secToken": "preferred-sec-token", + ], + ] + + let result = OneConsoleJSON.findFirstString( + forKeys: ["secToken", "token"], + in: value) + + #expect(result == "preferred-sec-token") + } + + @Test + func `lookup skips invalid values before a nested valid value`() { + let value: [String: Any] = [ + "count": "not-a-number", + "data": [ + "count": "42", + ], + ] + + #expect(OneConsoleJSON.findFirstInt(forKeys: ["count"], in: value) == 42) + } + + @Test + func `array lookup preserves key priority and skips invalid values`() { + let value: [String: Any] = [ + "fallback": [1], + "preferred": "not-an-array", + "data": [ + "preferred": [2, 3], + ], + ] + + let result = OneConsoleJSON.findFirstArray( + forKeys: ["preferred", "fallback"], + in: value) as? [Int] + + #expect(result == [2, 3]) + } + + @Test + func `date only string round trips`() throws { + let date = try #require(OneConsoleJSON.date("2026-07-28")) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + + #expect(formatter.string(from: date) == "2026-07-28") + } + + @Test + func `numeric zero date is treated as missing`() { + #expect(OneConsoleJSON.date(0) == nil) + } +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 158b788795..c67c2c3b0d 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -7,6 +7,24 @@ import Testing @MainActor @Suite(.serialized) struct ProviderSettingsDescriptorTests { + @Test + func `provider settings refresh enables explicit browser retry`() async { + var observedInteraction: ProviderInteraction? + var browserRetryAllowed = false + + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await BrowserCookieAccessGate.withDeniedBrowsersForTesting([.chrome]) { + await ProviderSettingsRefreshInteraction.perform { + observedInteraction = ProviderInteractionContext.current + browserRetryAllowed = BrowserCookieAccessGate.shouldAttempt(.chrome) + } + } + } + + #expect(observedInteraction == .userInitiated) + #expect(browserRetryAllowed) + } + @Test func `toggle I ds are unique across providers`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-unique") diff --git a/Tests/CodexBarTests/QwenCloudProviderTests.swift b/Tests/CodexBarTests/QwenCloudProviderTests.swift new file mode 100644 index 0000000000..517588fa09 --- /dev/null +++ b/Tests/CodexBarTests/QwenCloudProviderTests.swift @@ -0,0 +1,823 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private func qwenCloudFixture(_ name: String) throws -> Data { + try Data( + contentsOf: #require(Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Fixtures/QwenCloud"))) +} + +struct QwenCloudSettingsReaderTests { + @Test + func `cookie reads from environment`() { + let cookie = QwenCloudSettingsReader.cookieHeader(environment: [ + QwenCloudSettingsReader.cookieHeaderKey: "\"login_aliyunid_ticket=ticket\"", + ]) + #expect(cookie == "login_aliyunid_ticket=ticket") + } + + @Test + func `quota URL infers HTTPS scheme`() { + let url = QwenCloudSettingsReader.quotaURL(environment: [ + QwenCloudSettingsReader.quotaURLKey: "quota.qwen-cloud.test/data/api.json", + ]) + + #expect(url?.scheme == "https") + #expect(url?.host == "quota.qwen-cloud.test") + } + + @Test + func `quota URL rejects non HTTPS schemes`() { + let httpURL = QwenCloudSettingsReader.quotaURL(environment: [ + QwenCloudSettingsReader.quotaURLKey: "http://quota.qwen-cloud.test/data/api.json", + ]) + + #expect(httpURL == nil) + } + + @Test + func `host override rejects non HTTPS schemes`() { + let httpHost = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "http://home.qwen-cloud.test", + ]) + let httpsHost = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "https://home.qwen-cloud.test", + ]) + + #expect(httpHost == nil) + #expect(httpsHost == "https://home.qwen-cloud.test") + } + + @Test + func `host override normalizes bare hosts to HTTPS`() { + let bareHost = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "home.qwen-cloud.test", + ]) + let bareHostWithPort = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "home.qwen-cloud.test:8443", + ]) + + #expect(bareHost == "https://home.qwen-cloud.test") + #expect(bareHostWithPort == "https://home.qwen-cloud.test:8443") + } + + @Test + func `bare host overrides build valid dashboard and quota URLs`() { + let environment = [QwenCloudSettingsReader.hostKey: "qwen-cloud.test"] + + let dashboard = QwenCloudUsageFetcher.dashboardURL(environment: environment) + #expect(dashboard.scheme == "https") + #expect(dashboard.host == "qwen-cloud.test") + #expect(dashboard.absoluteString.contains("/billing/subscription/token-plan-individual")) + + let quota = QwenCloudUsageFetcher.defaultQuotaURL(environment: environment) + #expect(quota.scheme == "https") + #expect(quota.host == "qwen-cloud.test") + #expect(quota.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + } + + @Test + func `default quota URL targets qwen data gateway usage API`() { + let url = QwenCloudUsageFetcher.defaultQuotaURL + #expect(url.host == "cs-data.qwencloud.com") + #expect(url.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + #expect(url.absoluteString.contains("sfm_bailian")) + } + + @Test + func `dashboard URL targets the individual token plan page`() { + let url = QwenCloudUsageFetcher.dashboardURL + #expect(url.host == "home.qwencloud.com") + #expect(url.absoluteString.contains("/billing/subscription/token-plan-individual")) + } +} + +struct QwenCloudUsageSnapshotTests { + @Test + func `provider labels current quota windows`() { + let metadata = QwenCloudProviderDescriptor.descriptor.metadata + + #expect(metadata.sessionLabel == "5-hour") + #expect(metadata.weeklyLabel == "Weekly") + #if os(macOS) + #expect(metadata.browserCookieOrder == [.chrome]) + #expect(QwenCloudWebFetchStrategy.browserOrder == [.chrome]) + #else + #expect(metadata.browserCookieOrder == nil) + #endif + } + + @Test + func `maps used and total quota to primary window`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset = Date(timeIntervalSince1970: 1_700_100_000) + let snapshot = QwenCloudUsageSnapshot( + planName: "Token Plan", + usedQuota: 250, + totalQuota: 1000, + remainingQuota: nil, + resetsAt: reset, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == reset) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits used") + #expect(usage.loginMethod(for: .qwencloud) == "Token Plan") + } +} + +@Suite(.serialized) +struct QwenCloudUsageParsingTests { + @Test + func `parses current token plan 5 hour and weekly usage`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let innerJSON = """ + { + "code": 0, + "data": { + "per5HourPercentage": 0.03, + "per5HourResetTime": 1700003600000, + "per1WeekPercentage": 0.01, + "per1WeekResetTime": 1700086400000 + }, + "success": true + } + """ + let payload: [String: Any] = [ + "data": [ + "DataV2": [ + "data": innerJSON, + ], + ], + "httpStatusCode": 200, + ] + let data = try JSONSerialization.data(withJSONObject: payload) + + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 3) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_700_003_600)) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_700_086_400)) + } + + @Test + func `parses nested equity list token plan payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let data = try qwenCloudFixture("nested_equity_list") + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data, now: now) + + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.remainingQuota == 875) + #expect(snapshot.usedQuota == 125) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_701_000_000)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + } + + @Test + func `parses flat subscription summary payload`() throws { + let data = try qwenCloudFixture("flat_subscription_summary") + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + + #expect(snapshot.totalQuota == 2000) + #expect(snapshot.remainingQuota == 1500) + #expect(snapshot.usedQuota == 500) + } + + @Test + func `login payload maps to login required`() throws { + let data = try qwenCloudFixture("login_required") + #expect(throws: QwenCloudUsageError.loginRequired) { + try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + } + } + + @Test + func `forbidden payload maps to invalid credentials`() throws { + let data = try qwenCloudFixture("forbidden") + #expect(throws: QwenCloudUsageError.invalidCredentials) { + try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + } + } + + @Test + func `non json payload maps to parse failed`() { + #expect(throws: QwenCloudUsageError.parseFailed("Invalid JSON response")) { + try QwenCloudUsageFetcher.parseUsageSnapshot(from: Data("not-json".utf8)) + } + } + + /// Real-world response shape returned for an authenticated Qwen Cloud account + /// with no active individual token-plan subscription. Captured live against + /// `home.qwencloud.com` (requestId/Uid redacted) — the API returns HTTP 200 + /// with `TotalCount: 0` and zeroed quota fields rather than an error, so the + /// parser must not report a false subscription. Fixture: `Fixtures/QwenCloud/no_active_subscription.json`. + @Test + func `authenticated account with no active subscription reports no quota`() throws { + let data = try qwenCloudFixture("no_active_subscription") + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + + // No subscription instance and zero total → no quota window to display. + #expect(snapshot.totalQuota == 0 || snapshot.totalQuota == nil) + #expect(snapshot.usedQuota == nil || snapshot.usedQuota == 0) + #expect(snapshot.remainingQuota == nil || snapshot.remainingQuota == 0) + // The primary rate window must not render a false "100% remaining" bar + // for a non-subscribed account. + #expect(snapshot.toUsageSnapshot().primary == nil) + } +} + +struct QwenCloudCookieHeaderTests { + @Test + func `builds URL scoped headers for API and dashboard`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".qwencloud.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".qwencloud.com"), + self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.aliyun.com"), + ] + + let headers = try #require(QwenCloudCookieHeader.headers(from: cookies)) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("login_current_pk=account")) + #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) + } + + @Test + func `cached headers preserve URL scoping`() throws { + let headers = QwenCloudCookieHeaders( + apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", + dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") + + let cached = try #require(QwenCloudCookieHeaders(qwenCloudCachedHeader: headers.cacheQwenCloudCookieHeader())) + + #expect(cached.apiCookieHeader.contains("api_only=api")) + #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(cached.dashboardCookieHeader.contains("dashboard_only=dashboard")) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } +} + +@Suite(.serialized) +struct QwenCloudFetchTests { + @Test + func `fetches usage with dashboard sec token preflight`() async throws { + let usageAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + let subscriptionAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription" + let quotaConfigAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config" + var requestedAPIs: [String] = [] + QwenCloudStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "qwen-cloud.test", + url.path == "/billing/subscription/token-plan-individual", + request.httpMethod == "GET" + { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + if url.host == "qwen-cloud.test", request.httpMethod == "POST" { + let body = Self.requestBodyString(from: request) + let form = try #require(URLComponents(string: "?\(body)")) + let formValues = Dictionary(uniqueKeysWithValues: form.queryItems?.compactMap { item in + item.value.map { (item.name, $0) } + } ?? []) + #expect(formValues["sec_token"] == "qwen-html-token") + #expect(formValues["product"] == "sfm_bailian") + let paramsData = try #require(formValues["params"]?.data(using: .utf8)) + let params = try #require(JSONSerialization.jsonObject(with: paramsData) as? [String: Any]) + let api = try #require(params["Api"] as? String) + let data = try #require(params["Data"] as? [String: Any]) + let cornerstone = try #require(data["cornerstoneParam"] as? [String: Any]) + #expect(cornerstone["consoleSite"] as? String == "QWENCLOUD") + requestedAPIs.append(api) + + let json: String + switch api { + case usageAPI: + json = """ + { + "data": { + "per5HourPercentage": 0.03, + "per5HourResetTime": 1700003600000, + "per1WeekPercentage": 0.01, + "per1WeekResetTime": 1700086400000 + } + } + """ + case subscriptionAPI: + #expect(data["commodityCode"] as? String == "sfm_tokenplansolo_public_intl") + json = #"{"data":{"specCode":"standard","status":"VALID"}}"# + case quotaConfigAPI: + json = """ + { + "data": { + "lite": { "five_hour": 1000, "weekly": 10000 }, + "standard": { "five_hour": 5000, "weekly": 50000 }, + "pro": { "five_hour": 10000, "weekly": 100000 } + } + } + """ + default: + throw URLError(.unsupportedURL) + } + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + defer { + QwenCloudStubURLProtocol.handler = nil + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [QwenCloudStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let transport = ProviderHTTPClient(session: session) + let snapshot = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + + #expect(requestedAPIs == [usageAPI, subscriptionAPI, quotaConfigAPI]) + #expect(snapshot.planName == "Standard") + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 3) + #expect(snapshot.toUsageSnapshot().primary?.resetDescription == "150 / 5,000 credits used") + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "500 / 50,000 credits used") + } + + @Test + func `login page csrf token maps to login required before API requests`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/billing/subscription/token-plan-individual" { + return Self.makeTransportResponse( + url: url, + body: """ + + Sign in + + + """, + statusCode: 200) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse(url: url, body: "{}", statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + await #expect(throws: QwenCloudUsageError.loginRequired) { + try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=expired", + dashboardCookieHeader: "login_aliyunid_ticket=expired", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + } + } + + @Test + func `dashboard timeout maps to network error when token fallbacks are empty`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/billing/subscription/token-plan-individual" { + throw URLError(.timedOut) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse(url: url, body: "{}", statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + let error = await #expect(throws: QwenCloudUsageError.self) { + try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + } + guard case .networkError = error else { + Issue.record("Expected networkError, got \(String(describing: error))") + return + } + } + + @Test + func `dashboard server failure maps to network error when token fallbacks are empty`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/billing/subscription/token-plan-individual" { + return Self.makeTransportResponse(url: url, body: "Unavailable", statusCode: 503) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse(url: url, body: "{}", statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + let error = await #expect(throws: QwenCloudUsageError.self) { + try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + } + guard case .networkError = error else { + Issue.record("Expected networkError, got \(String(describing: error))") + return + } + } + + @Test + func `dashboard failure still allows sec token cookie fallback`() async throws { + let dashboardURL = try #require(URL(string: "https://qwen-cloud.test/dashboard")) + let resolver = OneConsoleSECTokenResolver(configuration: .init( + dashboardURL: { _ in dashboardURL }, + userInfoPath: "/tool/user/info.json", + isLoginPage: { _ in false })) + let transport = ProviderHTTPTransportHandler { _ in + throw URLError(.timedOut) + } + + let resolved = try await resolver.resolve( + cookieHeader: "login_aliyunid_ticket=ticket; sec_token=cookie-token", + environment: [:], + transport: transport) + + #expect(resolved.value == "cookie-token") + #expect(resolved.source == .cookie) + } + + @Test + func `user info resolver preserves sec token key priority`() async throws { + let dashboardURL = try #require(URL(string: "https://qwen-cloud.test/dashboard")) + let resolver = OneConsoleSECTokenResolver(configuration: .init( + dashboardURL: { _ in dashboardURL }, + userInfoPath: "/tool/user/info.json", + isLoginPage: { _ in false })) + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/dashboard" { + return Self.makeTransportResponse(url: url, body: "", statusCode: 200) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse( + url: url, + body: """ + { + "token": "generic-token", + "data": { + "secToken": "", + "nested": { "secToken": "preferred-sec-token" } + } + } + """, + statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + let resolved = try await resolver.resolve( + cookieHeader: "login_aliyunid_ticket=ticket", + environment: [:], + transport: transport) + + #expect(resolved.value == "preferred-sec-token") + #expect(resolved.source == .userInfo) + } + + @Test + func `redirect routing strips cross origin credentials and blocks preserved bodies`() throws { + let apiURL = try #require(URL(string: "https://cs-data.qwencloud.com/data/api.json")) + let dashboardRedirect = try #require(URL(string: "https://home.qwencloud.com/redirected")) + let crossHostURL = try #require(URL(string: "https://signin.aliyun.com/login")) + let insecureURL = try #require(URL(string: "http://home.qwencloud.com/login")) + let untrustedPortURL = try #require(URL(string: "https://home.qwencloud.com:8443/login")) + let redirectResponse = try #require(HTTPURLResponse( + url: apiURL, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: ["Location": dashboardRedirect.absoluteString])) + let routing = OneConsoleCookieRouting( + apiURL: apiURL, + dashboardURL: dashboardRedirect, + apiCookieHeader: "api_cookie=value", + dashboardCookieHeader: "dashboard_cookie=value") + + var apiRequest = URLRequest(url: apiURL) + apiRequest.httpMethod = "POST" + apiRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + apiRequest.setValue("api_cookie=value", forHTTPHeaderField: "Cookie") + apiRequest.setValue("Bearer secret", forHTTPHeaderField: "Authorization") + apiRequest.setValue("Basic proxy-secret", forHTTPHeaderField: "Proxy-Authorization") + apiRequest.setValue("api-key-secret", forHTTPHeaderField: "x-api-key") + apiRequest.setValue("csrf-secret", forHTTPHeaderField: "x-csrf-token") + apiRequest.setValue("xsrf-secret", forHTTPHeaderField: "x-xsrf-token") + apiRequest.httpBody = Data("sec_token=secret".utf8) + + var crossHostRedirect = URLRequest(url: crossHostURL) + crossHostRedirect.httpMethod = "GET" + crossHostRedirect.setValue("old=value", forHTTPHeaderField: "Cookie") + crossHostRedirect.setValue("Bearer secret", forHTTPHeaderField: "Authorization") + crossHostRedirect.setValue("Basic proxy-secret", forHTTPHeaderField: "Proxy-Authorization") + crossHostRedirect.setValue("api-key-secret", forHTTPHeaderField: "x-api-key") + crossHostRedirect.setValue("csrf-secret", forHTTPHeaderField: "x-csrf-token") + crossHostRedirect.setValue("xsrf-secret", forHTTPHeaderField: "x-xsrf-token") + let routedCrossHost = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: crossHostRedirect)) + #expect(routedCrossHost.value(forHTTPHeaderField: "Cookie") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "Authorization") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "Proxy-Authorization") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "x-api-key") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "x-csrf-token") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "x-xsrf-token") == nil) + #expect(routedCrossHost.httpBody == nil) + + var sameHostRedirect = URLRequest(url: dashboardRedirect) + sameHostRedirect.httpMethod = "GET" + sameHostRedirect.setValue("old=value", forHTTPHeaderField: "Cookie") + sameHostRedirect.setValue("csrf-secret", forHTTPHeaderField: "x-csrf-token") + let routedDashboard = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: sameHostRedirect)) + #expect(routedDashboard.value(forHTTPHeaderField: "Cookie") == "dashboard_cookie=value") + #expect(routedDashboard.value(forHTTPHeaderField: "x-csrf-token") == nil) + #expect(routedDashboard.httpBody == nil) + + for statusCode in [307, 308] { + let preservedRedirectResponse = try #require(HTTPURLResponse( + url: apiURL, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Location": crossHostURL.absoluteString])) + + var apiRedirect = URLRequest(url: apiURL) + apiRedirect.httpMethod = "POST" + apiRedirect.httpBody = Data("sec_token=secret".utf8) + let routedAPI = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: preservedRedirectResponse, + to: apiRedirect)) + #expect(routedAPI.value(forHTTPHeaderField: "Cookie") == "api_cookie=value") + #expect(routedAPI.httpBody == Data("sec_token=secret".utf8)) + + var externalPreservedPOST = URLRequest(url: crossHostURL) + externalPreservedPOST.httpMethod = "POST" + externalPreservedPOST.httpBody = Data("sec_token=secret".utf8) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: preservedRedirectResponse, + to: externalPreservedPOST) == nil) + + var dashboardPreservedPOST = URLRequest(url: dashboardRedirect) + dashboardPreservedPOST.httpMethod = "POST" + dashboardPreservedPOST.httpBody = Data("sec_token=secret".utf8) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: preservedRedirectResponse, + to: dashboardPreservedPOST) == nil) + } + + var untrustedPortRedirect = URLRequest(url: untrustedPortURL) + untrustedPortRedirect.setValue("old=value", forHTTPHeaderField: "Cookie") + let routedUntrustedPort = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: untrustedPortRedirect)) + #expect(routedUntrustedPort.value(forHTTPHeaderField: "Cookie") == nil) + + let insecureRedirect = URLRequest(url: insecureURL) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: insecureRedirect) == nil) + + let notModified = try #require(HTTPURLResponse( + url: apiURL, + statusCode: 304, + httpVersion: "HTTP/1.1", + headerFields: nil)) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: notModified, + to: crossHostRedirect) == nil) + } + + private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func makeTransportResponse( + url: URL, + body: String, + statusCode: Int) -> (Data, URLResponse) + { + let (response, data) = self.makeResponse(url: url, body: body, statusCode: statusCode) + return (data, response) + } + + private static func requestBodyString(from request: URLRequest) -> String { + if let data = request.httpBody { + return String(data: data, encoding: .utf8) ?? "" + } + if let stream = request.httpBodyStream { + stream.open() + defer { + stream.close() + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + return String(data: data, encoding: .utf8) ?? "" + } + return "" + } +} + +struct QwenCloudCookieImportValidationTests { + #if os(macOS) + @Test + func `accepts passport ticket sessions`() { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"), + ] + #expect(QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `accepts qwen scoped sso sessions`() { + let cookies = [ + self.cookie(name: "qwen_sso_ticket", value: "sso-ticket", domain: ".qwencloud.com"), + ] + #expect(QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `accepts current qwen cloud login tickets`() { + let cookies = [ + self.cookie(name: "login_qwencloud_ticket", value: "ticket", domain: ".qwencloud.com"), + ] + #expect(QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `rejects locale and account cookies without a login ticket`() { + // A browser profile that merely visited qwencloud.com carries locale + // preferences, account-id markers, and CSRF cookies while logged out; + // none of them prove an authenticated session. + let cookies = [ + self.cookie(name: "locale_pref", value: "en-US", domain: ".qwencloud.com"), + self.cookie(name: "login_aliyunid_pk", value: "1234567890", domain: ".qwencloud.com"), + self.cookie(name: "login_current_pk", value: "1234567890", domain: ".home.qwencloud.com"), + self.cookie(name: "sec_token", value: "csrf-token", domain: ".home.qwencloud.com"), + ] + #expect(!QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `rejects sessions without recognized cookies`() { + let cookies = [ + self.cookie(name: "unrelated", value: "x", domain: ".example.com"), + ] + #expect(!QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } + #endif +} + +final class QwenCloudStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return host == "home.qwencloud.com" || host == "qwen-cloud.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +/// Opt-in live smoke test against the real Qwen Cloud console. +/// +/// Disabled by default so CI / `make test` never touch the network or Keychain. +/// To run it against your own account: +/// 1. Copy a `Cookie:` header from `https://home.qwencloud.com/billing/subscription/token-plan-individual` +/// 2. Temporarily remove the `.disabled(...)` trait below (keep the guard) +/// 3. QWEN_CLOUD_LIVE_TEST=1 QWEN_CLOUD_COOKIE='login_aliyunid_ticket=...; ...' \ +/// swift test --filter QwenCloudLiveSmokeTests +@Suite(.serialized) +struct QwenCloudLiveSmokeTests { + @Test(.disabled("Set QWEN_CLOUD_LIVE_TEST=1 and QWEN_CLOUD_COOKIE to run live Qwen Cloud checks.")) + func `live token plan usage resolves`() async throws { + let environment = ProcessInfo.processInfo.environment + guard environment["QWEN_CLOUD_LIVE_TEST"] == "1" else { return } + guard let cookie = environment["QWEN_CLOUD_COOKIE"], + !cookie.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + Issue.record("QWEN_CLOUD_COOKIE is not set; paste a Cookie header from the Qwen Cloud billing page.") + return + } + + let snapshot = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: cookie, + environment: environment) + + func describe(_ value: (some Any)?) -> String { + value.map { "\($0)" } ?? "" + } + + print( + """ + [qwen-cloud-live] plan=\(describe(snapshot.planName)) \ + used=\(describe(snapshot.usedQuota)) \ + total=\(describe(snapshot.totalQuota)) \ + remaining=\(describe(snapshot.remainingQuota)) \ + resetsAt=\(describe(snapshot.resetsAt)) + """) + + // An authenticated account must not be treated as logged out. + #expect(snapshot.updatedAt > Date(timeIntervalSince1970: 0)) + // A subscribed account reports a total; a free/empty account may legitimately be nil. + if snapshot.totalQuota == nil { + print("[qwen-cloud-live] No active token-plan total reported (account may have no subscription).") + } else { + #expect((snapshot.totalQuota ?? 0) >= 0) + } + } +} diff --git a/docs/configuration.md b/docs/configuration.md index e743f65daa..766bc9a740 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -269,7 +269,7 @@ z.ai team accounts also use `usageScope`, `organizationId`, and `workspaceID`; s ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/index.html b/docs/index.html index 99b0ab5bcd..429c914c71 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ CodexBar — every AI coding limit, in your menu bar @@ -37,7 +37,7 @@ @@ -293,7 +293,7 @@

- 63 providers,{mobileBreak}one menu bar + 64 providers,{mobileBreak}one menu bar

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus. diff --git a/docs/llms.txt b/docs/llms.txt index bc9837997a..0a6467a4ff 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,9 +1,9 @@ # CodexBar -A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 63 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 64 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Canonical documentation: -- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 63 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 64 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Source: https://github.com/steipete/CodexBar diff --git a/docs/providers.md b/docs/providers.md index 2be0fdeb74..38d123d1ee 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 63 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 64 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -45,6 +45,7 @@ scan fails, while provider/account configuration changes replace obsolete result | OpenCode Go | Unscoped Auto: local SQLite usage (`local`) → web dashboard (`web`). Scoped Auto (selected account/manual cookie/workspace): web → local. Explicit Web: web only. | | Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | | Alibaba Token Plan | Bailian subscription summary API via browser or manual cookies (`web`). | +| Qwen Cloud | Qwen Cloud 5-hour/weekly Token Plan APIs via browser or manual cookies (`web`). | | Droid/Factory | API key (`FACTORY_API_KEY` / config) → web cookies → stored tokens → local storage → WorkOS cookies (`auto`, `api`, `web`). | | Devin | Chrome localStorage session or manual Bearer token → daily and weekly quota API (`web`). | | z.ai | API token from config/env → quota API (`api`). | @@ -225,6 +226,37 @@ scan fails, while provider/account configuration changes replace obsolete result - Status: `https://status.aliyun.com` (link only, no auto-polling). - Details: `docs/alibaba-token-plan.md`. +### Aliyun OneConsole family + +Alibaba Coding Plan, Alibaba Token Plan, and Qwen Cloud all run on the Aliyun OneConsole +backend. They selectively reuse plumbing under +`Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/`: + +- `AliyunOneConsoleCookieImporter` — browser cookie iteration, Chromium fallback, Keychain preflight. + The Alibaba providers and Qwen Cloud supply their own cookie domains and authenticated-session predicate. +- `OneConsoleCookieHeaders` / `OneConsoleCookieHeaderBuilder` — `apiCookieHeader` / `dashboardCookieHeader` + pair with cached-header round-trip, currently used by Alibaba Token Plan and Qwen Cloud. +- `OneConsoleJSON` — recursive expand-embedded-JSON traversal and scalar coercion (`number`, `int`, + `string`, `date`, `percentagePoints`), used by all three providers. +- `OneConsoleSECTokenResolver` — dashboard HTML → cookie → user-info chain, currently used by Qwen Cloud. +- `OneConsoleCookieRouting` — Qwen Cloud's provider-local redirect policy. It pins dashboard/API cookies + to their matching trusted origin, strips credentials from cross-origin GET/HEAD navigation, and blocks + cross-origin redirects that preserve a request body. + +Future OneConsole-based providers can adopt the helpers that match their actual protocol while keeping +provider-specific cookie validation, endpoints, login detection, and error translation at the provider boundary. + +## Qwen Cloud +- Web mode resolves `sec_token` through the dashboard (`home.qwencloud.com`), then posts to the current + individual Token Plan usage, subscription, and quota-configuration APIs on `cs-data.qwencloud.com`. +- Displays 5-hour and weekly consumed percentages, reset times, active tier, and tier-specific credit limits. +- Cookie sources: Chrome import (`auto`), manual Cookie header, or `QWEN_CLOUD_COOKIE`. +- Default data gateway: + `https://cs-data.qwencloud.com/data/api.json?action=IntlBroadScopeAspnGateway&product=sfm_bailian`. +- Host overrides: `QWEN_CLOUD_HOST` or `QWEN_CLOUD_QUOTA_URL` (HTTPS URLs or bare hosts normalized to HTTPS). +- Status: `https://status.alibabacloud.com` (link only, no auto-polling). +- Details: `docs/qwen-cloud.md`. + ## Droid (Factory) - API key from `~/.codexbar/config.json` (`providers[].apiKey`), `FACTORY_API_KEY`, or `~/.factory/.env`. - Web API via Factory cookies, bearer tokens, and WorkOS refresh tokens. diff --git a/docs/qwen-cloud.md b/docs/qwen-cloud.md new file mode 100644 index 0000000000..01193010dd --- /dev/null +++ b/docs/qwen-cloud.md @@ -0,0 +1,68 @@ +--- +summary: "Qwen Cloud provider notes: cookie auth, 5-hour and weekly token-plan usage, and setup." +read_when: + - Adding or modifying the Qwen Cloud provider + - Debugging Qwen Cloud cookie import or token-plan usage fetching + - Explaining Qwen Cloud setup and limitations to users +--- + +# Qwen Cloud Provider + +The Qwen Cloud provider tracks the **token plan (individual)** subscription credits from the +Qwen Cloud console (`home.qwencloud.com`), including plans that grant hosted Claude model access. + +## Features + +- **Current quota windows**: Shows 5-hour and weekly usage percentages, reset times, and plan-specific + credit limits from the same APIs used by the Qwen Cloud dashboard. +- **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. +- **Adjustable menu-bar display**: In **Settings → Menu Bar**, add the session/weekly percentage or usage-bar + items and arrange them like any other provider. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Qwen Cloud** +3. Leave **Cookie source** on **Auto** (recommended) + +### Manual cookie import (optional) + +1. Open `https://home.qwencloud.com/billing/subscription/token-plan-individual` +2. Copy a `Cookie:` header from your browser's Network tab +3. Paste it into **Qwen Cloud → Cookie source → Manual** + +## How it works + +- Calls Qwen Cloud's current individual Token Plan APIs through the `sfm_bailian` console gateway: + `personal/api/v2/usage`, `personal/api/v2/subscription`, and `personal/api/v2/quota-config`. +- The usage response supplies the 5-hour and weekly consumed ratios and reset times. The subscription response + identifies the active tier, and quota configuration supplies that tier's numeric credit limits. +- Sends form-encoded fields for `product=sfm_bailian`, `action=IntlBroadScopeAspnGateway`, + `region=ap-southeast-1`, `language=en-US`, a resolved `sec_token`, and the provider-native API payload. +- Uses Qwen Cloud / alibabacloud login cookies, with `sec_token` resolved from the dashboard HTML, + a `sec_token` cookie, or the `/tool/user/info.json` endpoint +- Supports `QWEN_CLOUD_HOST` and `QWEN_CLOUD_QUOTA_URL` for testing endpoint overrides, and + `QWEN_CLOUD_COOKIE` for an environment-supplied cookie header +- Endpoint overrides accept full `https://` URLs or bare hosts (for example, + `QWEN_CLOUD_HOST=home.qwen-cloud.test`), which are normalized to HTTPS; non-HTTPS schemes are rejected + +## Limitations + +- Qwen Cloud currently supports the web-cookie path only +- API-key auth, token cost summaries, and automatic status polling are not supported +- The default endpoint targets the international Qwen Cloud individual Token Plan APIs + +## Troubleshooting + +### "No Qwen Cloud session cookies found in browsers" + +Log in at `https://home.qwencloud.com/billing/subscription/token-plan-individual` in Chrome, then refresh CodexBar. + +### "Qwen Cloud cookie header is invalid" + +The pasted header is empty or not a valid Cookie header. Re-copy the request from the Token Plan page after +logging in again. + +### "Qwen Cloud login required" + +Your Qwen Cloud session is stale. Sign out and back in on the Qwen Cloud console, then refresh CodexBar. diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs index 41733b78ef..b43f18c91e 100644 --- a/docs/site-locales.mjs +++ b/docs/site-locales.mjs @@ -98,8 +98,8 @@ export const localeCatalog = [ export const localeMessages = { "en": { "meta.title": "CodexBar — every AI coding limit in your menu bar", - "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 63 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", - "meta.ogDescription": "Track usage windows, credits, and resets across 63 AI coding providers from your macOS menu bar.", + "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 64 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", + "meta.ogDescription": "Track usage windows, credits, and resets across 64 AI coding providers from your macOS menu bar.", "nav.primary": "Primary", "nav.language": "Language", "nav.docs": "docs", @@ -112,7 +112,7 @@ export const localeMessages = { "hero.description": "CodexBar tracks usage windows, credit balances, and reset countdowns across the providers you actually pay for — one status item each, or merge them into one.", "hero.download": "Download for macOS", "hero.fineprint": "Free & open source · macOS 14+ · Universal via GitHub Releases and Homebrew", - "providers.title": "63 providers,{mobileBreak}one menu bar", + "providers.title": "64 providers,{mobileBreak}one menu bar", "providers.description": "Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.", "providers.yourProvider": "Your provider", "providers.authoringGuide": "Authoring guide", @@ -238,8 +238,8 @@ export const localeMessages = { }, "zh-CN": { "meta.title": "CodexBar — 菜单栏中的每个 AI 编码限制", - "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 63 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", - "meta.ogDescription": "从您的 macOS 菜单栏跟踪 63 个 AI 编码提供商的使用窗口、积分和重置。", + "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 64 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", + "meta.ogDescription": "从您的 macOS 菜单栏跟踪 64 个 AI 编码提供商的使用窗口、积分和重置。", "nav.primary": "基本的", "nav.language": "语言", "nav.docs": "文档", @@ -252,7 +252,7 @@ export const localeMessages = { "hero.description": "CodexBar 跟踪您实际付费的提供商的使用窗口、信用余额和重置倒计时 - 每个状态项一项,或将它们合并为一项。", "hero.download": "下载macOS", "hero.fineprint": "免费开源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "63 个提供商,{mobileBreak}一个菜单栏", + "providers.title": "64 个提供商,{mobileBreak}一个菜单栏", "providers.description": "受欢迎的提供商成为状态项目,具有自己的使用窗口、重置倒计时、图表和提供商菜单。", "providers.yourProvider": "您的提供商", "providers.authoringGuide": "创作指南", @@ -378,8 +378,8 @@ export const localeMessages = { }, "zh-TW": { "meta.title": "CodexBar — 功能表列中的每個 AI 編碼限制", - "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 63 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", - "meta.ogDescription": "從您的 macOS 功能表列追蹤 63 個 AI 編碼提供者的使用視窗、積分和重設。", + "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 64 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", + "meta.ogDescription": "從您的 macOS 功能表列追蹤 64 個 AI 編碼提供者的使用視窗、積分和重設。", "nav.primary": "基本的", "nav.language": "語言", "nav.docs": "文件", @@ -392,7 +392,7 @@ export const localeMessages = { "hero.description": "CodexBar 追蹤您實際付費的提供者的使用視窗、信用餘額和重設倒數計時 - 每個狀態項一項,或將它們合併為一項。", "hero.download": "下載macOS", "hero.fineprint": "免費開源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "63 個提供者,{mobileBreak}一個選單列", + "providers.title": "64 個提供者,{mobileBreak}一個選單列", "providers.description": "受歡迎的提供者成為狀態項目,具有自己的使用視窗、重置倒數計時、圖表和提供者選單。", "providers.yourProvider": "您的提供者", "providers.authoringGuide": "創作指南", @@ -518,8 +518,8 @@ export const localeMessages = { }, "ja-JP": { "meta.title": "CodexBar — メニュー バーのすべての AI コーディング制限", - "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、63 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", - "meta.ogDescription": "macOS メニュー バーから、63 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", + "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、64 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", + "meta.ogDescription": "macOS メニュー バーから、64 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", "nav.primary": "主要な", "nav.language": "言語", "nav.docs": "ドキュメント", @@ -532,7 +532,7 @@ export const localeMessages = { "hero.description": "CodexBar は、実際に料金を支払っているプロバイダー全体の使用期間、クレジット残高、リセット カウントダウンを追跡します。ステータス項目を 1 つずつ、または 1 つに統合します。", "hero.download": "macOS のダウンロード", "hero.fineprint": "無料・オープンソース · macOS 14+ · GitHub Releases と Homebrew でユニバーサル版を提供", - "providers.title": "63 プロバイダー、{mobileBreak}1つのメニューバー", + "providers.title": "64 プロバイダー、{mobileBreak}1つのメニューバー", "providers.description": "人気のあるプロバイダーは、独自の使用期間、リセット カウントダウン、グラフ、プロバイダー メニューを備えたステータス アイテムになります。", "providers.yourProvider": "あなたのプロバイダー", "providers.authoringGuide": "オーサリングガイド", @@ -658,8 +658,8 @@ export const localeMessages = { }, "es": { "meta.title": "CodexBar: cada límite de codificación de IA en tu barra de menú", - "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 63 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", - "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 63 proveedores de codificación de IA desde su barra de menú macOS.", + "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 64 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", + "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 64 proveedores de codificación de IA desde su barra de menú macOS.", "nav.primary": "Primario", "nav.language": "Idioma", "nav.docs": "Documentos", @@ -672,7 +672,7 @@ export const localeMessages = { "hero.description": "CodexBar realiza un seguimiento de los períodos de uso, los saldos de crédito y restablece las cuentas regresivas de los proveedores por los que realmente paga: un elemento de estado para cada uno o los fusiona en uno solo.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratis y de código abierto · macOS 14+ · Universal mediante GitHub Releases y Homebrew", - "providers.title": "63 proveedores,{mobileBreak}una barra de menús", + "providers.title": "64 proveedores,{mobileBreak}una barra de menús", "providers.description": "Los proveedores populares se convierten en elementos de estado con sus propias ventanas de uso, restablecen cuentas regresivas, gráficos y menús de proveedores.", "providers.yourProvider": "Tu proveedor", "providers.authoringGuide": "guía de autoría", @@ -798,8 +798,8 @@ export const localeMessages = { }, "pt-BR": { "meta.title": "CodexBar — todos os limites de codificação de IA na sua barra de menu", - "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 63 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", - "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 63 provedores de codificação de IA na barra de menu macOS.", + "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 64 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", + "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 64 provedores de codificação de IA na barra de menu macOS.", "nav.primary": "Primário", "nav.language": "Linguagem", "nav.docs": "Documentos", @@ -812,7 +812,7 @@ export const localeMessages = { "hero.description": "CodexBar rastreia janelas de uso, saldos de crédito e reinicia contagens regressivas nos provedores pelos quais você realmente paga - um item de status cada, ou mescla-os em um.", "hero.download": "Baixar para macOS", "hero.fineprint": "Gratuito e de código aberto · macOS 14+ · Universal via GitHub Releases e Homebrew", - "providers.title": "63 provedores,{mobileBreak}uma barra de menu", + "providers.title": "64 provedores,{mobileBreak}uma barra de menu", "providers.description": "Provedores populares tornam-se itens de status com suas próprias janelas de uso, reiniciam contagens regressivas, gráficos e menus de provedores.", "providers.yourProvider": "Seu provedor", "providers.authoringGuide": "Guia de autoria", @@ -938,8 +938,8 @@ export const localeMessages = { }, "ko": { "meta.title": "CodexBar — 메뉴 표시줄의 모든 AI 코딩 제한", - "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 63개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", - "meta.ogDescription": "macOS 메뉴 표시줄에서 63개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", + "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 64개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", + "meta.ogDescription": "macOS 메뉴 표시줄에서 64개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", "nav.primary": "주요한", "nav.language": "언어", "nav.docs": "문서", @@ -952,7 +952,7 @@ export const localeMessages = { "hero.description": "CodexBar는 귀하가 실제로 비용을 지불한 제공업체 전반에 걸쳐 사용 기간, 크레딧 잔액 및 재설정 카운트다운을 추적합니다. 즉, 각각 하나의 상태 항목 또는 하나로 병합됩니다.", "hero.download": "macOS 동안 다운로드", "hero.fineprint": "무료 오픈 소스 · macOS 14+ · GitHub Releases와 Homebrew에서 유니버설 제공", - "providers.title": "63개 제공자,{mobileBreak}하나의 메뉴 막대", + "providers.title": "64개 제공자,{mobileBreak}하나의 메뉴 막대", "providers.description": "인기 있는 제공업체는 자체 사용 창, 재설정 카운트다운, 차트 및 제공업체 메뉴를 갖춘 상태 항목이 됩니다.", "providers.yourProvider": "귀하의 제공자", "providers.authoringGuide": "저작 가이드", @@ -1078,8 +1078,8 @@ export const localeMessages = { }, "de": { "meta.title": "CodexBar – alle Limits Ihrer KI-Coding-Tools in der Menüleiste", - "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 63 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", - "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 63 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", + "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 64 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", + "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 64 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", "nav.primary": "Primär", "nav.language": "Sprache", "nav.docs": "Dokumente", @@ -1092,7 +1092,7 @@ export const localeMessages = { "hero.description": "CodexBar verfolgt Nutzungsfenster, Guthaben und Reset-Countdowns bei den Anbietern, für die Sie tatsächlich bezahlen – jeweils ein Statuselement oder sie werden zu einem zusammengeführt.", "hero.download": "Herunterladen für macOS", "hero.fineprint": "Kostenlos und Open Source · macOS 14+ · Universal über GitHub Releases und Homebrew", - "providers.title": "63 Provider,{mobileBreak}eine Menüleiste", + "providers.title": "64 Provider,{mobileBreak}eine Menüleiste", "providers.description": "Beliebte Anbieter werden zu Statuselementen mit eigenen Nutzungsfenstern, Reset-Countdowns, Diagrammen und Anbietermenüs.", "providers.yourProvider": "Ihr Anbieter", "providers.authoringGuide": "Autorenleitfaden", @@ -1218,8 +1218,8 @@ export const localeMessages = { }, "fr": { "meta.title": "CodexBar — chaque limite de codage IA dans votre barre de menus", - "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 63 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", - "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 63 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", + "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 64 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", + "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 64 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", "nav.primary": "Primaire", "nav.language": "Langue", "nav.docs": "Documents", @@ -1232,7 +1232,7 @@ export const localeMessages = { "hero.description": "CodexBar suit les fenêtres d'utilisation, les soldes créditeurs et réinitialise les comptes à rebours pour les fournisseurs pour lesquels vous payez réellement - un élément de statut chacun, ou les fusionne en un seul.", "hero.download": "Télécharger pour macOS", "hero.fineprint": "Gratuit et open source · macOS 14+ · Universel via GitHub Releases et Homebrew", - "providers.title": "63 fournisseurs,{mobileBreak}une barre des menus", + "providers.title": "64 fournisseurs,{mobileBreak}une barre des menus", "providers.description": "Les fournisseurs populaires deviennent des éléments de statut avec leurs propres fenêtres d'utilisation, réinitialisent les comptes à rebours, les graphiques et les menus des fournisseurs.", "providers.yourProvider": "Votre fournisseur", "providers.authoringGuide": "Guide de création", @@ -1358,8 +1358,8 @@ export const localeMessages = { }, "ar": { "meta.title": "CodexBar — كل حد لترميز الذكاء الاصطناعي في شريط القائمة", - "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 63 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", - "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 63 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", + "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 64 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", + "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 64 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", "nav.primary": "أساسي", "nav.language": "لغة", "nav.docs": "المستندات", @@ -1372,7 +1372,7 @@ export const localeMessages = { "hero.description": "يتتبع CodexBar فترات الاستخدام والأرصدة الائتمانية وإعادة تعيين العد التنازلي عبر مقدمي الخدمة الذين تدفع مقابلهم فعليًا — عنصر حالة واحد لكل منهم، أو دمجهم في عنصر واحد.", "hero.download": "التنزيل لمدة macOS", "hero.fineprint": "مجاني ومفتوح المصدر · macOS 14+ · إصدار شامل عبر GitHub Releases وHomebrew", - "providers.title": "63 مزودًا،{mobileBreak}شريط قوائم واحد", + "providers.title": "64 مزودًا،{mobileBreak}شريط قوائم واحد", "providers.description": "يصبح الموفرون المشهورون عناصر حالة مع نوافذ الاستخدام الخاصة بهم، وعمليات إعادة تعيين العد التنازلي، والمخططات، وقوائم الموفر.", "providers.yourProvider": "المزود الخاص بك", "providers.authoringGuide": "دليل التأليف", @@ -1498,8 +1498,8 @@ export const localeMessages = { }, "it": { "meta.title": "CodexBar: ogni limite di codifica AI nella barra dei menu", - "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 63 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", - "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 63 fornitori di codifica AI dalla barra dei menu macOS.", + "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 64 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", + "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 64 fornitori di codifica AI dalla barra dei menu macOS.", "nav.primary": "Primario", "nav.language": "Lingua", "nav.docs": "Documenti", @@ -1512,7 +1512,7 @@ export const localeMessages = { "hero.description": "CodexBar tiene traccia delle finestre di utilizzo, dei saldi del credito e reimposta i conti alla rovescia tra i fornitori per cui paghi effettivamente: un elemento di stato ciascuno o uniscili in uno solo.", "hero.download": "Scarica per macOS", "hero.fineprint": "Gratuito e open source · macOS 14+ · Universale tramite GitHub Releases e Homebrew", - "providers.title": "63 provider,{mobileBreak}una barra dei menu", + "providers.title": "64 provider,{mobileBreak}una barra dei menu", "providers.description": "I fornitori più popolari diventano elementi di stato con le proprie finestre di utilizzo, reimpostano i conti alla rovescia, i grafici e i menu dei fornitori.", "providers.yourProvider": "Il tuo fornitore", "providers.authoringGuide": "Guida all'autore", @@ -1638,8 +1638,8 @@ export const localeMessages = { }, "vi": { "meta.title": "CodexBar — mọi giới hạn mã hóa AI trong thanh menu của bạn", - "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 63 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", - "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 63 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", + "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 64 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", + "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 64 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", "nav.primary": "Sơ đẳng", "nav.language": "Ngôn ngữ", "nav.docs": "Tài liệu", @@ -1652,7 +1652,7 @@ export const localeMessages = { "hero.description": "CodexBar theo dõi khoảng thời gian sử dụng, số dư tín dụng và đếm ngược đặt lại trên các nhà cung cấp mà bạn thực sự thanh toán — mỗi mục một trạng thái hoặc hợp nhất chúng thành một.", "hero.download": "Tải xuống cho macOS", "hero.fineprint": "Miễn phí và nguồn mở · macOS 14+ · Bản universal qua GitHub Releases và Homebrew", - "providers.title": "63 nhà cung cấp,{mobileBreak}một thanh menu", + "providers.title": "64 nhà cung cấp,{mobileBreak}một thanh menu", "providers.description": "Các nhà cung cấp phổ biến trở thành các mục trạng thái với cửa sổ sử dụng của riêng họ, đặt lại bộ đếm ngược, biểu đồ và menu nhà cung cấp.", "providers.yourProvider": "Nhà cung cấp của bạn", "providers.authoringGuide": "Hướng dẫn soạn thảo", @@ -1778,8 +1778,8 @@ export const localeMessages = { }, "nl": { "meta.title": "CodexBar — elke AI-coderingslimiet in uw menubalk", - "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 63 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", - "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 63 leveranciers van AI-codering via uw macOS-menubalk.", + "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 64 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", + "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 64 leveranciers van AI-codering via uw macOS-menubalk.", "nav.primary": "Primair", "nav.language": "Taal", "nav.docs": "Documenten", @@ -1792,7 +1792,7 @@ export const localeMessages = { "hero.description": "CodexBar houdt gebruiksperioden, tegoeden en reset-countdowns bij voor de providers waarvoor u daadwerkelijk betaalt: elk één statusitem, of u kunt ze samenvoegen tot één statusitem.", "hero.download": "Downloaden voor macOS", "hero.fineprint": "Gratis en open source · macOS 14+ · Universeel via GitHub Releases en Homebrew", - "providers.title": "63 providers,{mobileBreak}één menubalk", + "providers.title": "64 providers,{mobileBreak}één menubalk", "providers.description": "Populaire providers worden statusitems met hun eigen gebruiksvensters, resetcountdowns, grafieken en providermenu's.", "providers.yourProvider": "Uw aanbieder", "providers.authoringGuide": "Handleiding voor het schrijven", @@ -1918,8 +1918,8 @@ export const localeMessages = { }, "tr": { "meta.title": "CodexBar — menü çubuğunuzdaki tüm AI kodlama limitleri", - "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 63 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", - "meta.ogDescription": "macOS menü çubuğunu kullanarak 63 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", + "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 64 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", + "meta.ogDescription": "macOS menü çubuğunu kullanarak 64 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", "nav.primary": "Öncelik", "nav.language": "Dil", "nav.docs": "Dokümanlar", @@ -1932,7 +1932,7 @@ export const localeMessages = { "hero.description": "CodexBar, gerçekte ödeme yaptığınız sağlayıcılar genelinde kullanım pencerelerini, kredi bakiyelerini ve geri sayımları sıfırlar (her biri bir durum öğesi olacak şekilde) izler veya bunları tek bir öğede birleştirir.", "hero.download": "macOS için indirin", "hero.fineprint": "Ücretsiz ve açık kaynak · macOS 14+ · GitHub Releases ve Homebrew ile evrensel sürüm", - "providers.title": "63 sağlayıcı,{mobileBreak}bir menü çubuğu", + "providers.title": "64 sağlayıcı,{mobileBreak}bir menü çubuğu", "providers.description": "Popüler sağlayıcılar, kendi kullanım pencereleri, sıfırlama geri sayımları, çizelgeleri ve sağlayıcı menüleriyle durum öğeleri haline gelir.", "providers.yourProvider": "Sağlayıcınız", "providers.authoringGuide": "Yazma kılavuzu", @@ -2058,8 +2058,8 @@ export const localeMessages = { }, "uk": { "meta.title": "CodexBar — кожне обмеження кодування AI у вашій панелі меню", - "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 63 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", - "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 63 постачальників кодування ШІ за допомогою панелі меню macOS.", + "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 64 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", + "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 64 постачальників кодування ШІ за допомогою панелі меню macOS.", "nav.primary": "Первинний", "nav.language": "Мова", "nav.docs": "документи", @@ -2072,7 +2072,7 @@ export const localeMessages = { "hero.description": "CodexBar відстежує вікна використання, кредитні баланси та скидає зворотний відлік для постачальників, за яких ви фактично платите, — по одному статусу для кожного або об’єднує їх в один.", "hero.download": "Завантажити для macOS", "hero.fineprint": "Безкоштовний і відкритий код · macOS 14+ · Універсальна версія через GitHub Releases і Homebrew", - "providers.title": "63 провайдерів,{mobileBreak}одна панель меню", + "providers.title": "64 провайдерів,{mobileBreak}одна панель меню", "providers.description": "Популярні постачальники стають елементами статусу з власними вікнами використання, скиданням зворотного відліку, діаграмами та меню постачальників.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Авторський посібник", @@ -2198,8 +2198,8 @@ export const localeMessages = { }, "ru": { "meta.title": "CodexBar — все лимиты AI-кодинга в вашей строке меню", - "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 63 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", - "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 63 AI-провайдеров для кодинга прямо из строки меню macOS.", + "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 64 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", + "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 64 AI-провайдеров для кодинга прямо из строки меню macOS.", "nav.primary": "Основная навигация", "nav.language": "Язык", "nav.docs": "Документация", @@ -2212,7 +2212,7 @@ export const localeMessages = { "hero.description": "CodexBar отслеживает окна использования, балансы кредитов и обратный отсчет до сброса у провайдеров, за которых вы действительно платите, — по одному элементу статуса на каждого или все вместе в одном.", "hero.download": "Скачать для macOS", "hero.fineprint": "Бесплатно и с открытым исходным кодом · macOS 14+ · универсальная сборка через GitHub Releases и Homebrew", - "providers.title": "63 провайдеров,{mobileBreak}одна строка меню", + "providers.title": "64 провайдеров,{mobileBreak}одна строка меню", "providers.description": "Популярные провайдеры становятся элементами статуса со своими окнами использования, обратным отсчетом до сброса, графиками и меню провайдера.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Руководство по добавлению", @@ -2338,8 +2338,8 @@ export const localeMessages = { }, "id": { "meta.title": "CodexBar — setiap batas pengkodean AI di bilah menu Anda", - "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 63 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", - "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 63 penyedia pengkodean AI dari bilah menu macOS Anda.", + "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 64 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", + "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 64 penyedia pengkodean AI dari bilah menu macOS Anda.", "nav.primary": "Utama", "nav.language": "Bahasa", "nav.docs": "dokumen", @@ -2352,7 +2352,7 @@ export const localeMessages = { "hero.description": "CodexBar melacak jangka waktu penggunaan, saldo kredit, dan hitungan mundur penyetelan ulang di seluruh penyedia yang sebenarnya Anda bayar — masing-masing satu item status, atau gabungkan menjadi satu.", "hero.download": "Unduh untuk macOS", "hero.fineprint": "Gratis dan sumber terbuka · macOS 14+ · Universal melalui GitHub Releases dan Homebrew", - "providers.title": "63 penyedia,{mobileBreak}satu bilah menu", + "providers.title": "64 penyedia,{mobileBreak}satu bilah menu", "providers.description": "Penyedia populer menjadi item status dengan jendela penggunaannya sendiri, hitung mundur pengaturan ulang, bagan, dan menu penyedia.", "providers.yourProvider": "Penyedia Anda", "providers.authoringGuide": "Panduan penulisan", @@ -2478,8 +2478,8 @@ export const localeMessages = { }, "pl": { "meta.title": "CodexBar — każdy limit kodowania AI na pasku menu", - "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 63 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", - "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 63 dostawców kodowania AI za pomocą paska menu macOS.", + "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 64 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", + "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 64 dostawców kodowania AI za pomocą paska menu macOS.", "nav.primary": "Podstawowy", "nav.language": "Język", "nav.docs": "Dokumenty", @@ -2492,7 +2492,7 @@ export const localeMessages = { "hero.description": "CodexBar śledzi okna użytkowania, salda kredytów i resetuje odliczanie u dostawców, za których faktycznie płacisz — po jednym statusie dla każdego lub połącz je w jeden.", "hero.download": "Pobierz dla macOS", "hero.fineprint": "Darmowe i otwarte oprogramowanie · macOS 14+ · Wersja uniwersalna przez GitHub Releases i Homebrew", - "providers.title": "63 dostawców,{mobileBreak}jeden pasek menu", + "providers.title": "64 dostawców,{mobileBreak}jeden pasek menu", "providers.description": "Popularni dostawcy stają się elementami statusu z własnymi oknami użytkowania, resetowaniem odliczania, wykresami i menu dostawców.", "providers.yourProvider": "Twój dostawca", "providers.authoringGuide": "Przewodnik autorski", @@ -2618,8 +2618,8 @@ export const localeMessages = { }, "fa": { "meta.title": "CodexBar - هر محدودیت کدنویسی هوش مصنوعی در نوار منو شما", - "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 63 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", - "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 63 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", + "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 64 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", + "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 64 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", "nav.primary": "اولیه", "nav.language": "زبان", "nav.docs": "اسناد", @@ -2632,7 +2632,7 @@ export const localeMessages = { "hero.description": "CodexBar پنجره‌های استفاده، مانده اعتبار و بازنشانی شمارش معکوس را در سراسر ارائه‌دهندگانی که واقعاً برایشان پول پرداخت می‌کنید ردیابی می‌کند - هر کدام یک مورد وضعیت، یا آنها را در یکی ادغام کنید.", "hero.download": "دانلود برای macOS", "hero.fineprint": "رایگان و متن‌باز · macOS 14+ · نسخهٔ یونیورسال از GitHub Releases و Homebrew", - "providers.title": "63 ارائه دهنده،{mobileBreak}یک نوار منو", + "providers.title": "64 ارائه دهنده،{mobileBreak}یک نوار منو", "providers.description": "ارائه‌دهندگان محبوب با پنجره‌های استفاده خاص خود، شمارش معکوس، نمودارها و منوهای ارائه‌دهنده را بازنشانی می‌کنند.", "providers.yourProvider": "ارائه دهنده شما", "providers.authoringGuide": "راهنمای نگارش", @@ -2758,8 +2758,8 @@ export const localeMessages = { }, "th": { "meta.title": "CodexBar — ทุกขีดจำกัดการเข้ารหัส AI ในแถบเมนูของคุณ", - "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 63 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", - "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 63 รายจากแถบเมนู macOS", + "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 64 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", + "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 64 รายจากแถบเมนู macOS", "nav.primary": "หลัก", "nav.language": "ภาษา", "nav.docs": "เอกสาร", @@ -2772,7 +2772,7 @@ export const localeMessages = { "hero.description": "CodexBar ติดตามกรอบเวลาการใช้งาน ยอดเครดิต และรีเซ็ตการนับถอยหลังของผู้ให้บริการที่คุณชำระเงินจริง — รายการสถานะแต่ละรายการ หรือรวมรายการเหล่านั้นเป็นรายการเดียว", "hero.download": "ดาวน์โหลดสำหรับ macOS", "hero.fineprint": "ฟรีและโอเพ่นซอร์ส · macOS 14+ · รุ่น Universal ผ่าน GitHub Releases และ Homebrew", - "providers.title": "ผู้ให้บริการ 63 ราย{mobileBreak}หนึ่งแถบเมนู", + "providers.title": "ผู้ให้บริการ 64 ราย{mobileBreak}หนึ่งแถบเมนู", "providers.description": "ผู้ให้บริการยอดนิยมจะกลายเป็นรายการสถานะที่มีหน้าต่างการใช้งานของตนเอง รีเซ็ตการนับถอยหลัง แผนภูมิ และเมนูของผู้ให้บริการ", "providers.yourProvider": "ผู้ให้บริการของคุณ", "providers.authoringGuide": "คู่มือการเขียน", @@ -2898,8 +2898,8 @@ export const localeMessages = { }, "gl": { "meta.title": "CodexBar — todos os límites de programación con IA na túa barra de menús", - "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 63 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", - "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 63 provedores de programación con IA desde a barra de menús de macOS.", + "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 64 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", + "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 64 provedores de programación con IA desde a barra de menús de macOS.", "nav.primary": "Principal", "nav.language": "Idioma", "nav.docs": "Documentación", @@ -2912,7 +2912,7 @@ export const localeMessages = { "hero.description": "CodexBar controla as xanelas de uso, os saldos de crédito e as contas atrás ata o restablecemento dos provedores polos que realmente pagas — un elemento de estado para cada un ou todos combinados nun só.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratuíto e de código aberto · macOS 14+ · Universal mediante GitHub Releases e Homebrew", - "providers.title": "63 provedores,{mobileBreak}unha barra de menús", + "providers.title": "64 provedores,{mobileBreak}unha barra de menús", "providers.description": "Os provedores populares convértense en elementos de estado coas súas propias xanelas de uso, contas atrás de restablecemento, gráficas e menús.", "providers.yourProvider": "O teu provedor", "providers.authoringGuide": "Guía de creación", @@ -3038,8 +3038,8 @@ export const localeMessages = { }, "ca": { "meta.title": "CodexBar: tots els límits de la programació amb IA a la barra de menús", - "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 63 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", - "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 63 proveïdors de programació amb IA des de la barra de menús del macOS.", + "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 64 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", + "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 64 proveïdors de programació amb IA des de la barra de menús del macOS.", "nav.primary": "Navegació principal", "nav.language": "Llengua", "nav.docs": "Documentació", @@ -3052,7 +3052,7 @@ export const localeMessages = { "hero.description": "El CodexBar fa el seguiment de les finestres d'ús, els saldos de crèdit i els comptes enrere fins al restabliment dels proveïdors pels quals realment pagueu: un element d'estat per a cadascun, o combineu-los tots en un de sol.", "hero.download": "Baixeu per al macOS", "hero.fineprint": "Gratuït i de codi obert · macOS 14+ · Universal mitjançant GitHub Releases i Homebrew", - "providers.title": "63 proveïdors,{mobileBreak}una barra de menús", + "providers.title": "64 proveïdors,{mobileBreak}una barra de menús", "providers.description": "Els proveïdors populars es converteixen en elements d'estat amb les seves pròpies finestres d'ús, comptes enrere de restabliment, gràfics i menús de proveïdor.", "providers.yourProvider": "El vostre proveïdor", "providers.authoringGuide": "Guia de creació", @@ -3178,8 +3178,8 @@ export const localeMessages = { }, "sv": { "meta.title": "CodexBar — varje AI-kodningsgräns i din menyrad", - "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 63 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", - "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 63 AI-kodningsleverantörer från din macOS-menyrad.", + "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 64 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", + "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 64 AI-kodningsleverantörer från din macOS-menyrad.", "nav.primary": "Primär", "nav.language": "Språk", "nav.docs": "Dokument", @@ -3192,7 +3192,7 @@ export const localeMessages = { "hero.description": "CodexBar spårar användningsfönster, kreditsaldon och återställningsnedräkningar för de leverantörer du faktiskt betalar för – en statuspost var, eller slå samman dem till en.", "hero.download": "Ladda ner för macOS", "hero.fineprint": "Gratis och öppen källkod · macOS 14+ · Universal via GitHub Releases och Homebrew", - "providers.title": "63 leverantörer,{mobileBreak}en menyrad", + "providers.title": "64 leverantörer,{mobileBreak}en menyrad", "providers.description": "Populära leverantörer blir statusobjekt med sina egna användningsfönster, återställer nedräkningar, diagram och leverantörsmenyer.", "providers.yourProvider": "Din leverantör", "providers.authoringGuide": "Författarguide", diff --git a/docs/social.html b/docs/social.html index acbb6291c0..a62990619e 100644 --- a/docs/social.html +++ b/docs/social.html @@ -199,7 +199,7 @@

Every AI coding limit, in your menu bar.

-

63 providers·usage windows, credits, resets·one status item each, or merged.

+

64 providers·usage windows, credits, resets·one status item each, or merged.