From 66bf184feb9016dd86ff233d5e19fe2c2225a930 Mon Sep 17 00:00:00 2001 From: Breno Brito Date: Sun, 25 Jan 2026 22:14:29 -0300 Subject: [PATCH] Add Qwen Code provider Closes #247 --- README.md | 3 +- .../QwenCodeProviderImplementation.swift | 40 ++++ .../QwenCode/QwenCodeSettingsStore.swift | 15 ++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-qwencode.svg | 4 + Sources/CodexBar/SettingsStore+Defaults.swift | 8 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/SettingsStoreState.swift | 1 + Sources/CodexBar/UsageStore.swift | 4 + Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 17 ++ .../Providers/ProviderVersionDetector.swift | 13 ++ .../CodexBarCore/Providers/Providers.swift | 2 + .../QwenCode/QwenCodeProviderDescriptor.swift | 80 +++++++ .../QwenCode/QwenCodeUsageProbe.swift | 209 ++++++++++++++++++ .../QwenCode/QwenCodeUsageSnapshot.swift | 56 +++++ .../Vendored/CostUsage/CostUsageScanner.swift | 2 + .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../QwenCodeUsageProbeTests.swift | 76 +++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 1 + docs/providers.md | 10 +- docs/qwencode.md | 25 +++ 25 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 Sources/CodexBar/Providers/QwenCode/QwenCodeProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/QwenCode/QwenCodeSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-qwencode.svg create mode 100644 Sources/CodexBarCore/Providers/QwenCode/QwenCodeProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageProbe.swift create mode 100644 Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageSnapshot.swift create mode 100644 Tests/CodexBarTests/QwenCodeUsageProbeTests.swift create mode 100644 docs/qwencode.md diff --git a/README.md b/README.md index 757fbf03..a86fa906 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CodexBar 🎚️ - May your tokens never run out. -Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Qwen Code, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot @@ -38,6 +38,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - [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. - [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API. +- [Qwen Code](docs/qwencode.md) — Local Qwen Code session logs; daily request usage. - [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows. - [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit. - [Kimi K2](docs/kimi-k2.md) — API key for credit-based usage totals. diff --git a/Sources/CodexBar/Providers/QwenCode/QwenCodeProviderImplementation.swift b/Sources/CodexBar/Providers/QwenCode/QwenCodeProviderImplementation.swift new file mode 100644 index 00000000..19c382a1 --- /dev/null +++ b/Sources/CodexBar/Providers/QwenCode/QwenCodeProviderImplementation.swift @@ -0,0 +1,40 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct QwenCodeProviderImplementation: ProviderImplementation { + let id: UsageProvider = .qwencode + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "local" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.qwenCodeDailyRequestLimit + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .qwencode(context.settings.qwencodeSettingsSnapshot()) + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "qwencode-daily-request-limit", + title: "Daily request limit", + subtitle: "Used to compute the daily usage percentage.", + kind: .plain, + placeholder: "2000", + binding: context.stringBinding(\.qwenCodeDailyRequestLimit), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/QwenCode/QwenCodeSettingsStore.swift b/Sources/CodexBar/Providers/QwenCode/QwenCodeSettingsStore.swift new file mode 100644 index 00000000..2dde9b5e --- /dev/null +++ b/Sources/CodexBar/Providers/QwenCode/QwenCodeSettingsStore.swift @@ -0,0 +1,15 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + func qwencodeSettingsSnapshot() -> ProviderSettingsSnapshot.QwenCodeProviderSettings { + ProviderSettingsSnapshot.QwenCodeProviderSettings( + dailyRequestLimit: self.qwencodeRequestLimitValue()) + } + + private func qwencodeRequestLimitValue() -> Int? { + let raw = self.qwenCodeDailyRequestLimit.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { return nil } + return Int(raw) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index f6a9b2a3..d002d5ea 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -24,6 +24,7 @@ enum ProviderImplementationRegistry { case .minimax: MiniMaxProviderImplementation() case .kimi: KimiProviderImplementation() case .kiro: KiroProviderImplementation() + case .qwencode: QwenCodeProviderImplementation() case .vertexai: VertexAIProviderImplementation() case .augment: AugmentProviderImplementation() case .jetbrains: JetBrainsProviderImplementation() diff --git a/Sources/CodexBar/Resources/ProviderIcon-qwencode.svg b/Sources/CodexBar/Resources/ProviderIcon-qwencode.svg new file mode 100644 index 00000000..17f23085 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-qwencode.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 0e06b99f..daac4d80 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -231,6 +231,14 @@ extension SettingsStore { } } + var qwenCodeDailyRequestLimit: String { + get { self.defaultsState.qwenCodeDailyRequestLimit } + set { + self.defaultsState.qwenCodeDailyRequestLimit = newValue + self.userDefaults.set(newValue, forKey: "qwenCodeDailyRequestLimit") + } + } + var mergeIcons: Bool { get { self.defaultsState.mergeIcons } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 6c69f6a7..4e2aa60d 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -53,6 +53,7 @@ extension SettingsStore { _ = self.augmentCookieHeader _ = self.ampCookieHeader _ = self.copilotAPIToken + _ = self.qwenCodeDailyRequestLimit _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern _ = self.selectedMenuProvider diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 42514476..8a1f08bc 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -207,6 +207,7 @@ extension SettingsStore { let openAIWebAccessEnabled = openAIWebAccessDefault ?? true if openAIWebAccessDefault == nil { userDefaults.set(true, forKey: "openAIWebAccessEnabled") } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" + let qwenCodeDailyRequestLimit = userDefaults.string(forKey: "qwenCodeDailyRequestLimit") ?? "2000" let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") @@ -237,6 +238,7 @@ extension SettingsStore { showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, + qwenCodeDailyRequestLimit: qwenCodeDailyRequestLimit, mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, selectedMenuProviderRaw: selectedMenuProviderRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 9d8e833b..6fdfcbc0 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -25,6 +25,7 @@ struct SettingsDefaultsState: Sendable { var showOptionalCreditsAndExtraUsage: Bool var openAIWebAccessEnabled: Bool var jetbrainsIDEBasePath: String + var qwenCodeDailyRequestLimit: String var mergeIcons: Bool var switcherShowsIcons: Bool var selectedMenuProviderRaw: String? diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 69491056..fa96d45b 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1207,6 +1207,10 @@ extension UsageStore { let text = "Kiro debug log not yet implemented" await MainActor.run { self.probeLogs[.kiro] = text } return text + case .qwencode: + let text = "Qwen Code debug log not yet implemented" + await MainActor.run { self.probeLogs[.qwencode] = text } + return text case .augment: let text = await self.debugAugmentLog() await MainActor.run { self.probeLogs[.augment] = text } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index a0a88371..75264609 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -147,7 +147,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic: + case .gemini, .antigravity, .copilot, .kiro, .qwencode, .vertexai, .kimik2, .synthetic: return nil } } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6aff8369..43ce3651 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -65,6 +65,7 @@ public enum ProviderDescriptorRegistry { .minimax: MiniMaxProviderDescriptor.descriptor, .kimi: KimiProviderDescriptor.descriptor, .kiro: KiroProviderDescriptor.descriptor, + .qwencode: QwenCodeProviderDescriptor.descriptor, .vertexai: VertexAIProviderDescriptor.descriptor, .augment: AugmentProviderDescriptor.descriptor, .jetbrains: JetBrainsProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index f83cb9fd..5270153b 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -15,6 +15,7 @@ public struct ProviderSettingsSnapshot: Sendable { kimi: KimiProviderSettings? = nil, augment: AugmentProviderSettings? = nil, amp: AmpProviderSettings? = nil, + qwencode: QwenCodeProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( @@ -31,6 +32,7 @@ public struct ProviderSettingsSnapshot: Sendable { kimi: kimi, augment: augment, amp: amp, + qwencode: qwencode, jetbrains: jetbrains) } @@ -157,6 +159,14 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct QwenCodeProviderSettings: Sendable { + public let dailyRequestLimit: Int? + + public init(dailyRequestLimit: Int?) { + self.dailyRequestLimit = dailyRequestLimit + } + } + public struct AmpProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -180,6 +190,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let kimi: KimiProviderSettings? public let augment: AugmentProviderSettings? public let amp: AmpProviderSettings? + public let qwencode: QwenCodeProviderSettings? public let jetbrains: JetBrainsProviderSettings? public var jetbrainsIDEBasePath: String? { @@ -200,6 +211,7 @@ public struct ProviderSettingsSnapshot: Sendable { kimi: KimiProviderSettings?, augment: AugmentProviderSettings?, amp: AmpProviderSettings?, + qwencode: QwenCodeProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled @@ -215,6 +227,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.kimi = kimi self.augment = augment self.amp = amp + self.qwencode = qwencode self.jetbrains = jetbrains } } @@ -231,6 +244,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case kimi(ProviderSettingsSnapshot.KimiProviderSettings) case augment(ProviderSettingsSnapshot.AugmentProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) + case qwencode(ProviderSettingsSnapshot.QwenCodeProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) } @@ -248,6 +262,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var kimi: ProviderSettingsSnapshot.KimiProviderSettings? public var augment: ProviderSettingsSnapshot.AugmentProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? + public var qwencode: ProviderSettingsSnapshot.QwenCodeProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { @@ -268,6 +283,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .kimi(value): self.kimi = value case let .augment(value): self.augment = value case let .amp(value): self.amp = value + case let .qwencode(value): self.qwencode = value case let .jetbrains(value): self.jetbrains = value } } @@ -287,6 +303,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { kimi: self.kimi, augment: self.augment, amp: self.amp, + qwencode: self.qwencode, jetbrains: self.jetbrains) } } diff --git a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift index e79d0487..51bf5e47 100644 --- a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift +++ b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift @@ -28,6 +28,19 @@ public enum ProviderVersionDetector { return nil } + public static func qwenVersion() -> String? { + guard let path = TTYCommandRunner.which("qwen") else { return nil } + let candidates = [ + ["--version"], + ["-v"], + ["version"], + ] + for args in candidates { + if let version = Self.run(path: path, args: args) { return version } + } + return nil + } + private static func run(path: String, args: [String]) -> String? { let proc = Process() proc.executableURL = URL(fileURLWithPath: path) diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index a267fb95..e284652e 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -15,6 +15,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case minimax case kimi case kiro + case qwencode case vertexai case augment case jetbrains @@ -39,6 +40,7 @@ public enum IconStyle: Sendable, CaseIterable { case kimi case kimik2 case kiro + case qwencode case vertexai case augment case jetbrains diff --git a/Sources/CodexBarCore/Providers/QwenCode/QwenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeProviderDescriptor.swift new file mode 100644 index 00000000..2b9a80c9 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeProviderDescriptor.swift @@ -0,0 +1,80 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum QwenCodeProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .qwencode, + metadata: ProviderMetadata( + id: .qwencode, + displayName: "Qwen Code", + sessionLabel: "Requests", + weeklyLabel: "Tokens", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Qwen Code usage", + cliName: "qwen", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://chat.qwen.ai", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .qwencode, + iconResourceName: "ProviderIcon-qwencode", + color: ProviderColor(red: 32 / 255, green: 140 / 255, blue: 220 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Qwen Code cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [QwenCodeLocalFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "qwen", + aliases: ["qwen-code"], + versionDetector: { _ in ProviderVersionDetector.qwenVersion() })) + } +} + +struct QwenCodeLocalFetchStrategy: ProviderFetchStrategy { + let id: String = "qwencode.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let baseDirectory = QwenCodeUsageProbe.resolveBaseDirectory(env: context.env) + return QwenCodeUsageProbe.projectsDirectoryExists(baseDirectory: baseDirectory) + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let baseDirectory = QwenCodeUsageProbe.resolveBaseDirectory(env: context.env) + let requestLimit = Self.resolveRequestLimit(settings: context.settings, env: context.env) + let probe = QwenCodeUsageProbe(requestLimit: requestLimit, baseDirectory: baseDirectory) + let snapshot = try probe.fetch() + return self.makeResult( + usage: snapshot.toUsageSnapshot(requestLimit: requestLimit), + sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveRequestLimit(settings: ProviderSettingsSnapshot?, env: [String: String]) -> Int { + if let override = env["CODEXBAR_QWENCODE_DAILY_REQUEST_LIMIT"], + let limit = Int(override.trimmingCharacters(in: .whitespacesAndNewlines)), + limit > 0 + { + return limit + } + + if let limit = settings?.qwencode?.dailyRequestLimit, limit > 0 { + return limit + } + + return QwenCodeUsageProbe.defaultDailyRequestLimit + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageProbe.swift b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageProbe.swift new file mode 100644 index 00000000..6f706a93 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageProbe.swift @@ -0,0 +1,209 @@ +import Foundation + +public enum QwenCodeUsageProbeError: LocalizedError, Sendable, Equatable { + case projectsDirectoryMissing(String) + case noChatLogsFound + case invalidRequestLimit(Int) + + public var errorDescription: String? { + switch self { + case let .projectsDirectoryMissing(path): + "Qwen Code projects directory not found at \(path). Run qwen at least once to create logs." + case .noChatLogsFound: + "No Qwen Code chat logs found. Start a session and try again." + case let .invalidRequestLimit(limit): + "Qwen Code daily request limit is invalid (\(limit)). Set a positive value in Settings." + } + } +} + +public struct QwenCodeUsageProbe: Sendable { + public static let defaultDailyRequestLimit = 2000 + + private static let qwenDirName = ".qwen" + private static let projectsDirName = "projects" + private static let chatsDirName = "chats" + private static let oauthCredsFile = "oauth_creds.json" + + private let requestLimit: Int + private let baseDirectory: URL + private let now: Date + + public init(requestLimit: Int, baseDirectory: URL, now: Date = Date()) { + self.requestLimit = requestLimit + self.baseDirectory = baseDirectory + self.now = now + } + + public static func resolveBaseDirectory(env: [String: String]) -> URL { + if let override = env["CODEXBAR_QWEN_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: (override as NSString).expandingTildeInPath) + } + if let override = env["QWEN_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: (override as NSString).expandingTildeInPath) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(self.qwenDirName) + } + + public static func projectsDirectoryExists(baseDirectory: URL) -> Bool { + let projectsURL = baseDirectory.appendingPathComponent(self.projectsDirName) + return FileManager.default.fileExists(atPath: projectsURL.path) + } + + public func fetch() throws -> QwenCodeUsageSnapshot { + guard self.requestLimit > 0 else { + throw QwenCodeUsageProbeError.invalidRequestLimit(self.requestLimit) + } + + let projectsURL = self.baseDirectory.appendingPathComponent(Self.projectsDirName) + guard FileManager.default.fileExists(atPath: projectsURL.path) else { + throw QwenCodeUsageProbeError.projectsDirectoryMissing(projectsURL.path) + } + + let (windowStart, windowEnd) = self.dailyWindow(now: self.now) + var totalRequests = 0 + var totalTokens = 0 + var sawAnyLogs = false + + let projectDirs = (try? FileManager.default.contentsOfDirectory( + at: projectsURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles])) ?? [] + + for projectURL in projectDirs { + guard projectURL.hasDirectoryPath else { continue } + let chatsURL = projectURL.appendingPathComponent(Self.chatsDirName) + guard FileManager.default.fileExists(atPath: chatsURL.path) else { continue } + + let chatFiles = (try? FileManager.default.contentsOfDirectory( + at: chatsURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles])) ?? [] + + for fileURL in chatFiles where fileURL.pathExtension == "jsonl" { + sawAnyLogs = true + let data = (try? Data(contentsOf: fileURL)) ?? Data() + guard !data.isEmpty else { continue } + guard let text = String(bytes: data, encoding: .utf8) else { continue } + for line in text.split(whereSeparator: \.isNewline) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + guard let record = Self.decodeRecord(from: trimmed) else { continue } + guard let timestamp = record.timestamp, + let date = Self.parseTimestamp(timestamp) + else { + continue + } + guard date >= windowStart, date < windowEnd else { continue } + guard record.type == "assistant" else { continue } + guard let usage = record.usageMetadata else { continue } + totalRequests += 1 + totalTokens += usage.totalTokens + } + } + } + + guard sawAnyLogs else { + throw QwenCodeUsageProbeError.noChatLogsFound + } + + let (email, loginMethod) = self.loadIdentity() + + return QwenCodeUsageSnapshot( + requests: totalRequests, + totalTokens: totalTokens, + windowStart: windowStart, + windowEnd: windowEnd, + updatedAt: self.now, + accountEmail: email, + loginMethod: loginMethod) + } + + private func dailyWindow(now: Date) -> (Date, Date) { + let calendar = Calendar.current + let start = calendar.startOfDay(for: now) + let end = calendar.date(byAdding: .day, value: 1, to: start) ?? now + return (start, end) + } + + private func loadIdentity() -> (String?, String?) { + let credsURL = self.baseDirectory.appendingPathComponent(Self.oauthCredsFile) + guard let data = try? Data(contentsOf: credsURL), + let creds = try? JSONDecoder().decode(QwenCodeOAuthCredentials.self, from: data) + else { + return (nil, nil) + } + + let email: String? = creds.idToken.flatMap { token in + guard let payload = UsageFetcher.parseJWT(token) else { return nil } + if let raw = payload["email"] as? String { + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + return (email, "Qwen OAuth") + } + + private static func decodeRecord(from line: String) -> QwenCodeChatRecord? { + guard let data = line.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(QwenCodeChatRecord.self, from: data) + } + + private static func parseTimestamp(_ raw: String) -> Date? { + let formatterWithFractional = ISO8601DateFormatter() + formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatterWithFractional.date(from: raw) { + return date + } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: raw) + } +} + +private struct QwenCodeOAuthCredentials: Decodable { + let idToken: String? + + private enum CodingKeys: String, CodingKey { + case idToken = "id_token" + } +} + +private struct QwenCodeChatRecord: Decodable { + let type: String? + let timestamp: String? + let usageMetadata: QwenCodeUsageMetadata? + + private enum CodingKeys: String, CodingKey { + case type + case timestamp + case usageMetadata + } +} + +private struct QwenCodeUsageMetadata: Decodable { + let promptTokenCount: Int? + let candidatesTokenCount: Int? + let totalTokenCount: Int? + let cachedContentTokenCount: Int? + let thoughtsTokenCount: Int? + + var totalTokens: Int { + if let totalTokenCount { + return totalTokenCount + } + return [ + self.promptTokenCount, + self.candidatesTokenCount, + self.cachedContentTokenCount, + self.thoughtsTokenCount, + ] + .compactMap(\.self) + .reduce(0, +) + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageSnapshot.swift new file mode 100644 index 00000000..e634de3a --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageSnapshot.swift @@ -0,0 +1,56 @@ +import Foundation + +public struct QwenCodeUsageSnapshot: Sendable { + public let requests: Int + public let totalTokens: Int + public let windowStart: Date + public let windowEnd: Date + public let updatedAt: Date + public let accountEmail: String? + public let loginMethod: String? + + public init( + requests: Int, + totalTokens: Int, + windowStart: Date, + windowEnd: Date, + updatedAt: Date, + accountEmail: String?, + loginMethod: String?) + { + self.requests = requests + self.totalTokens = totalTokens + self.windowStart = windowStart + self.windowEnd = windowEnd + self.updatedAt = updatedAt + self.accountEmail = accountEmail + self.loginMethod = loginMethod + } + + public func toUsageSnapshot(requestLimit: Int) -> UsageSnapshot { + let usedPercent: Double = if requestLimit > 0 { + min(100, max(0, (Double(self.requests) / Double(requestLimit)) * 100)) + } else { + 0 + } + let resetDescription = UsageFormatter.resetDescription(from: self.windowEnd) + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: 24 * 60, + resetsAt: self.windowEnd, + resetDescription: resetDescription) + + let identity = ProviderIdentitySnapshot( + providerID: .qwencode, + accountEmail: self.accountEmail, + accountOrganization: nil, + loginMethod: self.loginMethod) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c50b106c..53bb3660 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -87,6 +87,8 @@ enum CostUsageScanner { return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .kiro: return CostUsageDailyReport(data: [], summary: nil) + case .qwencode: + return CostUsageDailyReport(data: [], summary: nil) case .kimi: return CostUsageDailyReport(data: [], summary: nil) case .kimik2: diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 1634611e..63b9f48c 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -53,6 +53,7 @@ enum ProviderChoice: String, AppEnum { case .minimax: self = .minimax case .vertexai: return nil // Vertex AI not yet supported in widgets case .kiro: return nil // Kiro not yet supported in widgets + case .qwencode: return nil // Qwen Code not yet supported in widgets case .augment: return nil // Augment not yet supported in widgets case .jetbrains: return nil // JetBrains not yet supported in widgets case .kimi: return nil // Kimi not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index ed39b450..53e4d525 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -269,6 +269,7 @@ private struct ProviderSwitchChip: View { case .minimax: "MiniMax" case .vertexai: "Vertex" case .kiro: "Kiro" + case .qwencode: "Qwen" case .augment: "Augment" case .jetbrains: "JetBrains" case .kimi: "Kimi" @@ -593,6 +594,8 @@ enum WidgetColors { Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue case .kiro: Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange + case .qwencode: + Color(red: 32 / 255, green: 140 / 255, blue: 220 / 255) case .augment: Color(red: 99 / 255, green: 102 / 255, blue: 241 / 255) // Augment purple case .jetbrains: diff --git a/Tests/CodexBarTests/QwenCodeUsageProbeTests.swift b/Tests/CodexBarTests/QwenCodeUsageProbeTests.swift new file mode 100644 index 00000000..82b07743 --- /dev/null +++ b/Tests/CodexBarTests/QwenCodeUsageProbeTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct QwenCodeUsageProbeTests { + @Test + func aggregatesDailyUsageFromLogs() throws { + let fm = FileManager.default + let baseDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? fm.removeItem(at: baseDir) } + + let chatsDir = baseDir + .appendingPathComponent("projects") + .appendingPathComponent("sample-project") + .appendingPathComponent("chats") + try fm.createDirectory(at: chatsDir, withIntermediateDirectories: true) + + let calendar = Calendar.current + let now = calendar.date(from: DateComponents(year: 2026, month: 1, day: 15, hour: 12, minute: 0))! + let yesterday = calendar.date(byAdding: .day, value: -1, to: now)! + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let todayStamp = formatter.string(from: now) + let yesterdayStamp = formatter.string(from: yesterday) + + let todayLine = "{\"type\":\"assistant\",\"timestamp\":\"\(todayStamp)\",\"usageMetadata\":" + + "{\"promptTokenCount\":10,\"candidatesTokenCount\":20,\"totalTokenCount\":30}}" + let yesterdayLine = "{\"type\":\"assistant\",\"timestamp\":\"\(yesterdayStamp)\",\"usageMetadata\":" + + "{\"promptTokenCount\":5,\"candidatesTokenCount\":5,\"totalTokenCount\":10}}" + let lines = [ + todayLine, + yesterdayLine, + "{\"type\":\"user\",\"timestamp\":\"\(todayStamp)\"}", + ] + let logURL = chatsDir.appendingPathComponent("session.jsonl") + try lines.joined(separator: "\n").write(to: logURL, atomically: true, encoding: .utf8) + + let probe = QwenCodeUsageProbe(requestLimit: 100, baseDirectory: baseDir, now: now) + let snapshot = try probe.fetch() + + #expect(snapshot.requests == 1) + #expect(snapshot.totalTokens == 30) + + let usage = snapshot.toUsageSnapshot(requestLimit: 100) + #expect(usage.primary?.usedPercent == 1) + } + + @Test + func throwsWhenProjectsDirectoryMissing() { + let fm = FileManager.default + let baseDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? fm.removeItem(at: baseDir) } + let now = Date() + + let probe = QwenCodeUsageProbe(requestLimit: 100, baseDirectory: baseDir, now: now) + + #expect(throws: QwenCodeUsageProbeError.self) { + try probe.fetch() + } + } + + @Test + func throwsWhenRequestLimitInvalid() { + let fm = FileManager.default + let baseDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? fm.removeItem(at: baseDir) } + + let probe = QwenCodeUsageProbe(requestLimit: 0, baseDirectory: baseDir) + + #expect(throws: QwenCodeUsageProbeError.self) { + try probe.fetch() + } + } +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 108d31bd..afd1d387 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -358,6 +358,7 @@ struct SettingsStoreTests { .minimax, .kimi, .kiro, + .qwencode, .vertexai, .augment, .jetbrains, diff --git a/docs/providers.md b/docs/providers.md index d25dfb88..94fd9135 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Vertex AI, Augment, Amp, JetBrains AI)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Qwen Code, Vertex AI, Augment, Amp, JetBrains AI)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -31,6 +31,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Copilot | API token (device flow/env) → copilot_internal API (`api`). | | Kimi K2 | API key (Keychain/env) → credit endpoint (`api`). | | Kiro | CLI command via `kiro-cli chat --no-interactive "/usage"` (`cli`). | +| Qwen Code | Local JSONL chat logs (`local`). | | Vertex AI | Google ADC OAuth (gcloud) → Cloud Monitoring quota usage (`oauth`). | | JetBrains AI | Local XML quota file (`local`). | | Amp | Web settings page via browser cookies (`web`). | @@ -121,6 +122,13 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: AWS Health Dashboard (manual link, no auto-polling). - Details: `docs/kiro.md`. +## Qwen Code +- Local JSONL logs under `~/.qwen/projects//chats/*.jsonl`. +- Counts assistant records with `usageMetadata` in the local-day window. +- Daily request limit defaults to 2,000; override in Settings → Providers → Qwen Code. +- Status: none yet. +- Details: `docs/qwencode.md`. + ## Vertex AI - OAuth credentials from `gcloud auth application-default login` (ADC). - Quota usage via Cloud Monitoring `consumer_quota` metrics for `aiplatform.googleapis.com`. diff --git a/docs/qwencode.md b/docs/qwencode.md new file mode 100644 index 00000000..244d0464 --- /dev/null +++ b/docs/qwencode.md @@ -0,0 +1,25 @@ +--- +summary: "Qwen Code provider: local session log parsing and daily request usage." +read_when: + - Debugging Qwen Code usage + - Updating Qwen Code provider behavior +--- + +# Qwen Code + +## Data source +- Local JSONL logs written by Qwen Code: + - `~/.qwen/projects//chats/*.jsonl` +- Each JSONL record includes `timestamp`, `type`, and (for assistant records) `usageMetadata`. + +## Usage model +- Counts assistant records with `usageMetadata` within the **local-day** window. +- Daily request limit defaults to **2,000** (Qwen OAuth free tier). +- Override in Settings → Providers → Qwen Code ("Daily request limit"). +- Optional environment override: `CODEXBAR_QWENCODE_DAILY_REQUEST_LIMIT`. + +## Identity +- If `~/.qwen/oauth_creds.json` exists, Qwen OAuth credentials are read and the email is derived from `id_token` when possible. + +## Notes +- No public usage/quota API exists yet; when available, add an API strategy and prefer it over local logs.