From 20efb95c0d7e6c4be81f3a5b88a8d0d937663d63 Mon Sep 17 00:00:00 2001
From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com>
Date: Mon, 9 Mar 2026 15:44:12 +0800
Subject: [PATCH 1/6] =?UTF-8?q?Add=20Qwen=20and=20Doubao=20(=E9=80=9A?=
=?UTF-8?q?=E4=B9=89=E7=81=B5=E7=A0=81=20&=20=E8=B1=86=E5=8C=85)=20provide?=
=?UTF-8?q?rs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Doubao/DoubaoProviderImplementation.swift | 42 +++
.../Doubao/DoubaoSettingsStore.swift | 14 +
.../Qwen/QwenProviderImplementation.swift | 42 +++
.../Providers/Qwen/QwenSettingsStore.swift | 14 +
.../ProviderImplementationRegistry.swift | 2 +
.../Resources/ProviderIcon-doubao.svg | 1 +
.../CodexBar/Resources/ProviderIcon-qwen.svg | 1 +
Sources/CodexBar/UsageStore.swift | 2 +-
Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +-
.../Config/ProviderConfigEnvironment.swift | 8 +
.../CodexBarCore/Logging/LogCategories.swift | 2 +
.../Doubao/DoubaoProviderDescriptor.swift | 69 +++++
.../Doubao/DoubaoSettingsReader.swift | 37 +++
.../Providers/Doubao/DoubaoUsageFetcher.swift | 245 +++++++++++++++++
.../Providers/ProviderDescriptor.swift | 2 +
.../Providers/ProviderTokenResolver.swift | 20 ++
.../CodexBarCore/Providers/Providers.swift | 4 +
.../Qwen/QwenProviderDescriptor.swift | 69 +++++
.../Providers/Qwen/QwenSettingsReader.swift | 36 +++
.../Providers/Qwen/QwenUsageFetcher.swift | 260 ++++++++++++++++++
.../Vendored/CostUsage/CostUsageScanner.swift | 2 +-
.../CodexBarWidgetProvider.swift | 2 +
.../CodexBarWidget/CodexBarWidgetViews.swift | 6 +
23 files changed, 879 insertions(+), 3 deletions(-)
create mode 100644 Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
create mode 100644 Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift
create mode 100644 Sources/CodexBar/Providers/Qwen/QwenProviderImplementation.swift
create mode 100644 Sources/CodexBar/Providers/Qwen/QwenSettingsStore.swift
create mode 100644 Sources/CodexBar/Resources/ProviderIcon-doubao.svg
create mode 100644 Sources/CodexBar/Resources/ProviderIcon-qwen.svg
create mode 100644 Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
create mode 100644 Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift
create mode 100644 Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
create mode 100644 Sources/CodexBarCore/Providers/Qwen/QwenProviderDescriptor.swift
create mode 100644 Sources/CodexBarCore/Providers/Qwen/QwenSettingsReader.swift
create mode 100644 Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift
diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
new file mode 100644
index 000000000..fe974bed8
--- /dev/null
+++ b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
@@ -0,0 +1,42 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct DoubaoProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .doubao
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.doubaoAPIToken
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "doubao-api-token",
+ title: "API key",
+ subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine "
+ + "Ark console.",
+ kind: .secure,
+ placeholder: "ark-...",
+ binding: context.stringBinding(\.doubaoAPIToken),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "doubao-open-dashboard",
+ title: "Open Volcengine Ark Console",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://console.volcengine.com/ark/") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift
new file mode 100644
index 000000000..4d69a273f
--- /dev/null
+++ b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift
@@ -0,0 +1,14 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var doubaoAPIToken: String {
+ get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .doubao) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .doubao, field: "apiKey", value: newValue)
+ }
+ }
+}
diff --git a/Sources/CodexBar/Providers/Qwen/QwenProviderImplementation.swift b/Sources/CodexBar/Providers/Qwen/QwenProviderImplementation.swift
new file mode 100644
index 000000000..5b981e26b
--- /dev/null
+++ b/Sources/CodexBar/Providers/Qwen/QwenProviderImplementation.swift
@@ -0,0 +1,42 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct QwenProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .qwen
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.qwenAPIToken
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "qwen-api-token",
+ title: "API key",
+ subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Alibaba Cloud "
+ + "Bailian console (DashScope).",
+ kind: .secure,
+ placeholder: "sk-...",
+ binding: context.stringBinding(\.qwenAPIToken),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "qwen-open-dashboard",
+ title: "Open Bailian Console",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://bailian.console.aliyun.com/") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Qwen/QwenSettingsStore.swift b/Sources/CodexBar/Providers/Qwen/QwenSettingsStore.swift
new file mode 100644
index 000000000..0aa570954
--- /dev/null
+++ b/Sources/CodexBar/Providers/Qwen/QwenSettingsStore.swift
@@ -0,0 +1,14 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var qwenAPIToken: String {
+ get { self.configSnapshot.providerConfig(for: .qwen)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .qwen) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .qwen, field: "apiKey", value: newValue)
+ }
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 7938b3d49..7aa59cdc3 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -35,6 +35,8 @@ enum ProviderImplementationRegistry {
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
+ case .qwen: QwenProviderImplementation()
+ case .doubao: DoubaoProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
new file mode 100644
index 000000000..9c20430a1
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/Resources/ProviderIcon-qwen.svg b/Sources/CodexBar/Resources/ProviderIcon-qwen.svg
new file mode 100644
index 000000000..46e5ab262
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-qwen.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index e3f0bcae4..fd7c4878c 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1241,7 +1241,7 @@ extension UsageStore {
let source = resolution?.source.rawValue ?? "none"
text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2,
- .jetbrains:
+ .jetbrains, .qwen, .doubao:
text = unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index 7324fa837..4ba781876 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -157,7 +157,7 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
- case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
+ case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp, .qwen, .doubao:
return nil
}
}
diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
index 9fabc4b80..8237e4088 100644
--- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
+++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
@@ -29,6 +29,14 @@ public enum ProviderConfigEnvironment {
}
case .openrouter:
env[OpenRouterSettingsReader.envKey] = apiKey
+ case .qwen:
+ if let key = QwenSettingsReader.apiKeyEnvironmentKeys.first {
+ env[key] = apiKey
+ }
+ case .doubao:
+ if let key = DoubaoSettingsReader.apiKeyEnvironmentKeys.first {
+ env[key] = apiKey
+ }
default:
break
}
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index 37a7726ef..faac90a2a 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -43,6 +43,8 @@ public enum LogCategories {
public static let ollama = "ollama"
public static let opencodeUsage = "opencode-usage"
public static let openRouterUsage = "openrouter-usage"
+ public static let qwenUsage = "qwen-usage"
+ public static let doubaoUsage = "doubao-usage"
public static let providerDetection = "provider-detection"
public static let providers = "providers"
public static let sessionQuota = "sessionQuota"
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
new file mode 100644
index 000000000..69fb57c89
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
@@ -0,0 +1,69 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum DoubaoProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .doubao,
+ metadata: ProviderMetadata(
+ id: .doubao,
+ displayName: "Doubao",
+ sessionLabel: "Requests",
+ weeklyLabel: "Rate limit",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Doubao usage",
+ cliName: "doubao",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: nil,
+ dashboardURL: "https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=subscribe",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .doubao,
+ iconResourceName: "ProviderIcon-doubao",
+ color: ProviderColor(red: 51 / 255, green: 112 / 255, blue: 255 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Doubao cost summary is not available." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DoubaoAPIFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "doubao",
+ aliases: ["volcengine", "ark", "bytedance"],
+ versionDetector: nil))
+ }
+}
+
+struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "doubao.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ Self.resolveToken(environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = Self.resolveToken(environment: context.env) else {
+ throw DoubaoUsageError.missingCredentials
+ }
+ let usage = try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "api")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveToken(environment: [String: String]) -> String? {
+ ProviderTokenResolver.doubaoToken(environment: environment)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift
new file mode 100644
index 000000000..43568d4a5
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+public struct DoubaoSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKeys = [
+ "ARK_API_KEY",
+ "VOLCENGINE_API_KEY",
+ "DOUBAO_API_KEY",
+ ]
+
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ for key in self.apiKeyEnvironmentKeys {
+ guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !raw.isEmpty
+ else {
+ continue
+ }
+ let cleaned = Self.cleaned(raw)
+ if !cleaned.isEmpty {
+ return cleaned
+ }
+ }
+ return nil
+ }
+
+ private static func cleaned(_ raw: String) -> String {
+ var value = raw
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
new file mode 100644
index 000000000..8e9f04c6e
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -0,0 +1,245 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct DoubaoUsageSnapshot: Sendable {
+ public let remainingRequests: Int
+ public let limitRequests: Int
+ public let resetTime: Date?
+ public let updatedAt: Date
+ public let apiKeyValid: Bool
+ public let totalTokens: Int?
+ public init(
+ remainingRequests: Int,
+ limitRequests: Int,
+ resetTime: Date?,
+ updatedAt: Date,
+ apiKeyValid: Bool = false,
+ totalTokens: Int? = nil)
+ {
+ self.remainingRequests = remainingRequests
+ self.limitRequests = limitRequests
+ self.resetTime = resetTime
+ self.updatedAt = updatedAt
+ self.apiKeyValid = apiKeyValid
+ self.totalTokens = totalTokens
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let usedPercent: Double
+ let resetDescription: String
+
+ if self.limitRequests > 0 {
+ let used = max(0, self.limitRequests - self.remainingRequests)
+ usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100))
+ resetDescription = "\(used)/\(self.limitRequests) requests"
+ } else if self.apiKeyValid {
+ usedPercent = 0
+ resetDescription = "Active — check dashboard for details"
+ } else {
+ usedPercent = 0
+ resetDescription = "No usage data"
+ }
+
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: self.resetTime,
+ resetDescription: resetDescription)
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .doubao,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
+
+public enum DoubaoUsageError: LocalizedError, Sendable {
+ case missingCredentials
+ case networkError(String)
+ case apiError(Int, String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Missing Doubao API key (ARK_API_KEY)."
+ case let .networkError(message):
+ "Doubao network error: \(message)"
+ case let .apiError(code, message):
+ "Doubao API error (\(code)): \(message)"
+ case let .parseFailed(message):
+ "Failed to parse Doubao response: \(message)"
+ }
+ }
+}
+
+public struct DoubaoUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.doubaoUsage)
+ private static let apiURL = URL(string: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions")!
+
+ public static func fetchUsage(apiKey: String) async throws -> DoubaoUsageSnapshot {
+ guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw DoubaoUsageError.missingCredentials
+ }
+
+ var request = URLRequest(url: self.apiURL)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 15
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+
+ let body: [String: Any] = [
+ "model": "doubao-seed-2.0-code",
+ "max_tokens": 1,
+ "messages": [
+ ["role": "user", "content": "hi"],
+ ] as [[String: Any]],
+ ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw DoubaoUsageError.networkError("Invalid response")
+ }
+
+ // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers.
+ guard httpResponse.statusCode == 200 || httpResponse.statusCode == 429 else {
+ let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data)
+ Self.log.error("Doubao API returned \(httpResponse.statusCode): \(summary)")
+ throw DoubaoUsageError.apiError(httpResponse.statusCode, summary)
+ }
+
+ let headers = httpResponse.allHeaderFields
+ let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests")
+ let limit = Self.intHeader(headers, "x-ratelimit-limit-requests")
+ let resetString = headers["x-ratelimit-reset-requests"] as? String
+
+ let resetTime: Date? = resetString.flatMap(Self.parseResetTime)
+
+ var totalTokens: Int?
+ if remaining == nil, limit == nil,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let usage = json["usage"] as? [String: Any]
+ {
+ totalTokens = usage["total_tokens"] as? Int
+ }
+
+ let snapshot = DoubaoUsageSnapshot(
+ remainingRequests: remaining ?? 0,
+ limitRequests: limit ?? 0,
+ resetTime: resetTime,
+ updatedAt: Date(),
+ apiKeyValid: httpResponse.statusCode == 200,
+ totalTokens: totalTokens)
+
+ Self.log.debug(
+ "Doubao usage parsed remaining=\(snapshot.remainingRequests) limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid)")
+
+ return snapshot
+ }
+
+ private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? {
+ if let value = headers[name] as? String, let int = Int(value) {
+ return int
+ }
+ if let value = headers[name.lowercased()] as? String, let int = Int(value) {
+ return int
+ }
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.lowercased() == name.lowercased(),
+ let valStr = val as? String,
+ let int = Int(valStr)
+ {
+ return int
+ }
+ }
+ return nil
+ }
+
+ private static func parseResetTime(_ value: String) -> Date? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return nil }
+
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = isoFormatter.date(from: trimmed) { return date }
+ let isoFallback = ISO8601DateFormatter()
+ isoFallback.formatOptions = [.withInternetDateTime]
+ if let date = isoFallback.date(from: trimmed) { return date }
+
+ var seconds: TimeInterval = 0
+ let pattern = /(\d+)([dhms])/
+ for match in trimmed.matches(of: pattern) {
+ guard let num = Double(match.1) else { continue }
+ switch match.2 {
+ case "d": seconds += num * 86400
+ case "h": seconds += num * 3600
+ case "m": seconds += num * 60
+ case "s": seconds += num
+ default: break
+ }
+ }
+ if seconds > 0 {
+ return Date().addingTimeInterval(seconds)
+ }
+
+ if let secs = TimeInterval(trimmed) {
+ return Date().addingTimeInterval(secs)
+ }
+
+ return nil
+ }
+
+ private static func apiErrorSummary(statusCode: Int, data: Data) -> String {
+ guard let root = try? JSONSerialization.jsonObject(with: data),
+ let json = root as? [String: Any]
+ else {
+ if let text = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !text.isEmpty
+ {
+ return Self.compactText(text)
+ }
+ return "Unexpected response body (\(data.count) bytes)."
+ }
+
+ if let error = json["error"] as? [String: Any],
+ let message = error["message"] as? String
+ {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ if let message = json["message"] as? String {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ return "HTTP \(statusCode) (\(data.count) bytes)."
+ }
+
+ private static func compactText(_ text: String, maxLength: Int = 200) -> String {
+ let collapsed = text
+ .components(separatedBy: .newlines)
+ .joined(separator: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if collapsed.count <= maxLength { return collapsed }
+ let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength)
+ return "\(collapsed[.. String? {
+ self.qwenResolution(environment: environment)?.token
+ }
+
+ public static func doubaoToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.doubaoResolution(environment: environment)?.token
+ }
+
public static func zaiResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
@@ -141,6 +149,18 @@ public enum ProviderTokenResolver {
self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment))
}
+ public static func qwenResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(QwenSettingsReader.apiKey(environment: environment))
+ }
+
+ public static func doubaoResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(DoubaoSettingsReader.apiKey(environment: environment))
+ }
+
private static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index f48eefe43..8aa534416 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -25,6 +25,8 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case synthetic
case warp
case openrouter
+ case qwen
+ case doubao
}
// swiftformat:enable sortDeclarations
@@ -52,6 +54,8 @@ public enum IconStyle: Sendable, CaseIterable {
case synthetic
case warp
case openrouter
+ case qwen
+ case doubao
case combined
}
diff --git a/Sources/CodexBarCore/Providers/Qwen/QwenProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Qwen/QwenProviderDescriptor.swift
new file mode 100644
index 000000000..207fba3ea
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Qwen/QwenProviderDescriptor.swift
@@ -0,0 +1,69 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum QwenProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .qwen,
+ metadata: ProviderMetadata(
+ id: .qwen,
+ displayName: "Qwen",
+ sessionLabel: "Requests",
+ weeklyLabel: "Rate limit",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Qwen usage",
+ cliName: "qwen",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: nil,
+ dashboardURL: "https://bailian.console.aliyun.com/",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .qwen,
+ iconResourceName: "ProviderIcon-qwen",
+ color: ProviderColor(red: 106 / 255, green: 58 / 255, blue: 255 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Qwen cost summary is not available." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [QwenAPIFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "qwen",
+ aliases: ["tongyi", "dashscope", "lingma"],
+ versionDetector: nil))
+ }
+}
+
+struct QwenAPIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "qwen.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ Self.resolveToken(environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = Self.resolveToken(environment: context.env) else {
+ throw QwenUsageError.missingCredentials
+ }
+ let usage = try await QwenUsageFetcher.fetchUsage(apiKey: apiKey)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "api")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveToken(environment: [String: String]) -> String? {
+ ProviderTokenResolver.qwenToken(environment: environment)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Qwen/QwenSettingsReader.swift b/Sources/CodexBarCore/Providers/Qwen/QwenSettingsReader.swift
new file mode 100644
index 000000000..4045cc2aa
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Qwen/QwenSettingsReader.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+public struct QwenSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKeys = [
+ "DASHSCOPE_API_KEY",
+ "QWEN_API_KEY",
+ ]
+
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ for key in self.apiKeyEnvironmentKeys {
+ guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !raw.isEmpty
+ else {
+ continue
+ }
+ let cleaned = Self.cleaned(raw)
+ if !cleaned.isEmpty {
+ return cleaned
+ }
+ }
+ return nil
+ }
+
+ private static func cleaned(_ raw: String) -> String {
+ var value = raw
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift b/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift
new file mode 100644
index 000000000..53cfc0a51
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift
@@ -0,0 +1,260 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct QwenUsageSnapshot: Sendable {
+ public let remainingRequests: Int
+ public let limitRequests: Int
+ public let resetTime: Date?
+ public let updatedAt: Date
+ public let apiKeyValid: Bool
+ public let totalTokens: Int?
+ public init(
+ remainingRequests: Int,
+ limitRequests: Int,
+ resetTime: Date?,
+ updatedAt: Date,
+ apiKeyValid: Bool = false,
+ totalTokens: Int? = nil)
+ {
+ self.remainingRequests = remainingRequests
+ self.limitRequests = limitRequests
+ self.resetTime = resetTime
+ self.updatedAt = updatedAt
+ self.apiKeyValid = apiKeyValid
+ self.totalTokens = totalTokens
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let usedPercent: Double
+ let resetDescription: String
+
+ if self.limitRequests > 0 {
+ let used = max(0, self.limitRequests - self.remainingRequests)
+ usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100))
+ resetDescription = "\(used)/\(self.limitRequests) requests"
+ } else if self.apiKeyValid {
+ usedPercent = 0
+ resetDescription = "Active — check dashboard for details"
+ } else {
+ usedPercent = 0
+ resetDescription = "No usage data"
+ }
+
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: self.resetTime,
+ resetDescription: resetDescription)
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .qwen,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
+
+public enum QwenUsageError: LocalizedError, Sendable {
+ case missingCredentials
+ case networkError(String)
+ case apiError(Int, String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Missing Qwen API key (DASHSCOPE_API_KEY)."
+ case let .networkError(message):
+ "Qwen network error: \(message)"
+ case let .apiError(code, message):
+ "Qwen API error (\(code)): \(message)"
+ case let .parseFailed(message):
+ "Failed to parse Qwen response: \(message)"
+ }
+ }
+}
+
+public struct QwenUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.qwenUsage)
+
+ /// Coding plan keys (sk-sp-*) use the coding endpoint; regular keys use compatible mode.
+ private static func apiURL(for apiKey: String) -> URL {
+ if apiKey.hasPrefix("sk-sp-") {
+ URL(string: "https://coding.dashscope.aliyuncs.com/v1/chat/completions")!
+ } else {
+ URL(string: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions")!
+ }
+ }
+
+ public static func fetchUsage(apiKey: String) async throws -> QwenUsageSnapshot {
+ guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw QwenUsageError.missingCredentials
+ }
+
+ let url = self.apiURL(for: apiKey)
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 30
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+
+ let body: [String: Any] = [
+ "model": "qwen3-coder-plus",
+ "max_tokens": 1,
+ "messages": [
+ ["role": "user", "content": "hi"],
+ ] as [[String: Any]],
+ ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw QwenUsageError.networkError("Invalid response")
+ }
+
+ // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers.
+ guard httpResponse.statusCode == 200 || httpResponse.statusCode == 429 else {
+ let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data)
+ Self.log.error("Qwen API returned \(httpResponse.statusCode): \(summary)")
+ throw QwenUsageError.apiError(httpResponse.statusCode, summary)
+ }
+
+ let headers = httpResponse.allHeaderFields
+ let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests")
+ let limit = Self.intHeader(headers, "x-ratelimit-limit-requests")
+ let resetString = headers["x-ratelimit-reset-requests"] as? String
+
+ let resetTime: Date? = resetString.flatMap(Self.parseResetTime)
+
+ var totalTokens: Int?
+ if remaining == nil, limit == nil,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let usage = json["usage"] as? [String: Any]
+ {
+ totalTokens = usage["total_tokens"] as? Int
+ }
+
+ let snapshot = QwenUsageSnapshot(
+ remainingRequests: remaining ?? 0,
+ limitRequests: limit ?? 0,
+ resetTime: resetTime,
+ updatedAt: Date(),
+ apiKeyValid: httpResponse.statusCode == 200,
+ totalTokens: totalTokens)
+
+ Self.log.debug(
+ "Qwen usage parsed remaining=\(snapshot.remainingRequests) limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid)")
+
+ return snapshot
+ }
+
+ private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? {
+ if let value = headers[name] as? String, let int = Int(value) {
+ return int
+ }
+ if let value = headers[name.lowercased()] as? String, let int = Int(value) {
+ return int
+ }
+ // Case-insensitive search
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.lowercased() == name.lowercased(),
+ let valStr = val as? String,
+ let int = Int(valStr)
+ {
+ return int
+ }
+ }
+ return nil
+ }
+
+ /// Parse reset time from header value like "1d2h3m4s" or "30s" or ISO 8601.
+ private static func parseResetTime(_ value: String) -> Date? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return nil }
+
+ // Try ISO 8601 first
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = isoFormatter.date(from: trimmed) { return date }
+ let isoFallback = ISO8601DateFormatter()
+ isoFallback.formatOptions = [.withInternetDateTime]
+ if let date = isoFallback.date(from: trimmed) { return date }
+
+ // Try duration format like "1d2h3m4s" or "30s"
+ var seconds: TimeInterval = 0
+ let pattern = /(\d+)([dhms])/
+ for match in trimmed.matches(of: pattern) {
+ guard let num = Double(match.1) else { continue }
+ switch match.2 {
+ case "d": seconds += num * 86400
+ case "h": seconds += num * 3600
+ case "m": seconds += num * 60
+ case "s": seconds += num
+ default: break
+ }
+ }
+ if seconds > 0 {
+ return Date().addingTimeInterval(seconds)
+ }
+
+ // Try plain integer as seconds
+ if let secs = TimeInterval(trimmed) {
+ return Date().addingTimeInterval(secs)
+ }
+
+ return nil
+ }
+
+ private static func apiErrorSummary(statusCode: Int, data: Data) -> String {
+ guard let root = try? JSONSerialization.jsonObject(with: data),
+ let json = root as? [String: Any]
+ else {
+ if let text = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !text.isEmpty
+ {
+ return Self.compactText(text)
+ }
+ return "Unexpected response body (\(data.count) bytes)."
+ }
+
+ if let message = json["message"] as? String {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ if let error = json["error"] as? [String: Any],
+ let message = error["message"] as? String
+ {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ }
+
+ return "HTTP \(statusCode) (\(data.count) bytes)."
+ }
+
+ private static func compactText(_ text: String, maxLength: Int = 200) -> String {
+ let collapsed = text
+ .components(separatedBy: .newlines)
+ .joined(separator: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if collapsed.count <= maxLength { return collapsed }
+ let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength)
+ return "\(collapsed[..
Date: Fri, 13 Mar 2026 14:48:11 +0800
Subject: [PATCH 2/6] fix: address review feedback for Qwen & Doubao providers
1. Reset header lookup is now case-insensitive (new stringHeader helper),
matching the existing intHeader behavior.
2. On 429 (rate-limited), apiKeyValid is set to true so the UI shows
"Active" instead of "No usage data" when rate-limit headers are absent.
3. Probe multiple fallback models instead of hardcoding a single model,
so keys with different entitlements or regions still work.
---
.../Providers/Doubao/DoubaoUsageFetcher.swift | 48 ++++++++++++++++--
.../Providers/Qwen/QwenUsageFetcher.swift | 49 +++++++++++++++++--
2 files changed, 91 insertions(+), 6 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index 8e9f04c6e..9916ca904 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -88,11 +88,36 @@ public struct DoubaoUsageFetcher: Sendable {
private static let log = CodexBarLog.logger(LogCategories.doubaoUsage)
private static let apiURL = URL(string: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions")!
+ /// Models to probe, ordered by likelihood. We try multiple models because
+ /// different key types may not have access to every model.
+ private static let probeModels = [
+ "doubao-seed-2.0-code",
+ "doubao-1.5-pro-32k",
+ "doubao-lite-32k",
+ ]
+
public static func fetchUsage(apiKey: String) async throws -> DoubaoUsageSnapshot {
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw DoubaoUsageError.missingCredentials
}
+ var lastError: Error?
+ for model in self.probeModels {
+ do {
+ return try await self.probe(apiKey: apiKey, model: model)
+ } catch let error as DoubaoUsageError {
+ if case let .apiError(code, _) = error, code == 404 || code == 403 {
+ Self.log.debug("Doubao probe model \(model) unavailable (\(code)), trying next")
+ lastError = error
+ continue
+ }
+ throw error
+ }
+ }
+ throw lastError ?? DoubaoUsageError.apiError(0, "All probe models failed")
+ }
+
+ private static func probe(apiKey: String, model: String) async throws -> DoubaoUsageSnapshot {
var request = URLRequest(url: self.apiURL)
request.httpMethod = "POST"
request.timeoutInterval = 15
@@ -101,7 +126,7 @@ public struct DoubaoUsageFetcher: Sendable {
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let body: [String: Any] = [
- "model": "doubao-seed-2.0-code",
+ "model": model,
"max_tokens": 1,
"messages": [
["role": "user", "content": "hi"],
@@ -126,7 +151,7 @@ public struct DoubaoUsageFetcher: Sendable {
let headers = httpResponse.allHeaderFields
let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests")
let limit = Self.intHeader(headers, "x-ratelimit-limit-requests")
- let resetString = headers["x-ratelimit-reset-requests"] as? String
+ let resetString = Self.stringHeader(headers, "x-ratelimit-reset-requests")
let resetTime: Date? = resetString.flatMap(Self.parseResetTime)
@@ -138,12 +163,16 @@ public struct DoubaoUsageFetcher: Sendable {
totalTokens = usage["total_tokens"] as? Int
}
+ // 429 means the key is valid but rate-limited; treat it as valid so the UI
+ // shows "Active" instead of "No usage data" when headers are absent.
+ let keyValid = httpResponse.statusCode == 200 || httpResponse.statusCode == 429
+
let snapshot = DoubaoUsageSnapshot(
remainingRequests: remaining ?? 0,
limitRequests: limit ?? 0,
resetTime: resetTime,
updatedAt: Date(),
- apiKeyValid: httpResponse.statusCode == 200,
+ apiKeyValid: keyValid,
totalTokens: totalTokens)
Self.log.debug(
@@ -152,6 +181,19 @@ public struct DoubaoUsageFetcher: Sendable {
return snapshot
}
+ private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? {
+ if let value = headers[name] as? String { return value }
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.caseInsensitiveCompare(name) == .orderedSame,
+ let valStr = val as? String
+ {
+ return valStr
+ }
+ }
+ return nil
+ }
+
private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? {
if let value = headers[name] as? String, let int = Int(value) {
return int
diff --git a/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift b/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift
index 53cfc0a51..232f7e5ca 100644
--- a/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Qwen/QwenUsageFetcher.swift
@@ -96,11 +96,37 @@ public struct QwenUsageFetcher: Sendable {
}
}
+ /// Models to probe, ordered by likelihood. We try multiple models because
+ /// different key types / regions may not have access to every model.
+ private static let probeModels = [
+ "qwen3-coder-plus",
+ "qwen-turbo",
+ "qwen-plus",
+ ]
+
public static func fetchUsage(apiKey: String) async throws -> QwenUsageSnapshot {
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw QwenUsageError.missingCredentials
}
+ var lastError: Error?
+ for model in self.probeModels {
+ do {
+ return try await self.probe(apiKey: apiKey, model: model)
+ } catch let error as QwenUsageError {
+ // Model not found / not entitled → try next model
+ if case let .apiError(code, _) = error, code == 404 || code == 403 {
+ Self.log.debug("Qwen probe model \(model) unavailable (\(code)), trying next")
+ lastError = error
+ continue
+ }
+ throw error
+ }
+ }
+ throw lastError ?? QwenUsageError.apiError(0, "All probe models failed")
+ }
+
+ private static func probe(apiKey: String, model: String) async throws -> QwenUsageSnapshot {
let url = self.apiURL(for: apiKey)
var request = URLRequest(url: url)
@@ -111,7 +137,7 @@ public struct QwenUsageFetcher: Sendable {
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let body: [String: Any] = [
- "model": "qwen3-coder-plus",
+ "model": model,
"max_tokens": 1,
"messages": [
["role": "user", "content": "hi"],
@@ -136,7 +162,7 @@ public struct QwenUsageFetcher: Sendable {
let headers = httpResponse.allHeaderFields
let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests")
let limit = Self.intHeader(headers, "x-ratelimit-limit-requests")
- let resetString = headers["x-ratelimit-reset-requests"] as? String
+ let resetString = Self.stringHeader(headers, "x-ratelimit-reset-requests")
let resetTime: Date? = resetString.flatMap(Self.parseResetTime)
@@ -148,12 +174,16 @@ public struct QwenUsageFetcher: Sendable {
totalTokens = usage["total_tokens"] as? Int
}
+ // 429 means the key is valid but rate-limited; treat it as valid so the UI
+ // shows "Active" instead of "No usage data" when headers are absent.
+ let keyValid = httpResponse.statusCode == 200 || httpResponse.statusCode == 429
+
let snapshot = QwenUsageSnapshot(
remainingRequests: remaining ?? 0,
limitRequests: limit ?? 0,
resetTime: resetTime,
updatedAt: Date(),
- apiKeyValid: httpResponse.statusCode == 200,
+ apiKeyValid: keyValid,
totalTokens: totalTokens)
Self.log.debug(
@@ -162,6 +192,19 @@ public struct QwenUsageFetcher: Sendable {
return snapshot
}
+ private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? {
+ if let value = headers[name] as? String { return value }
+ for (key, val) in headers {
+ if let keyStr = key as? String,
+ keyStr.caseInsensitiveCompare(name) == .orderedSame,
+ let valStr = val as? String
+ {
+ return valStr
+ }
+ }
+ return nil
+ }
+
private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? {
if let value = headers[name] as? String, let int = Int(value) {
return int
From 614149014282dfda7ee19ecc015e092ede4e6dbf Mon Sep 17 00:00:00 2001
From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com>
Date: Fri, 13 Mar 2026 15:06:23 +0800
Subject: [PATCH 3/6] ci: add workflow_dispatch trigger to enable manual CI
runs
---
.github/workflows/ci.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index de2359aca..61e1aa737 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,7 @@ on:
push:
branches: ["*"]
pull_request:
+ workflow_dispatch:
jobs:
lint-build-test:
From eedbe8e8766ddb6eed833e8955543efa4b12fe3b Mon Sep 17 00:00:00 2001
From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com>
Date: Fri, 13 Mar 2026 15:18:30 +0800
Subject: [PATCH 4/6] ci: trigger CI build
From e43aee5c8b68f521beed8fe3e60830f38af49d57 Mon Sep 17 00:00:00 2001
From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com>
Date: Fri, 13 Mar 2026 15:26:37 +0800
Subject: [PATCH 5/6] ci: re-trigger CI after main sync
From d233cdedc51b15beaced5a22d5bffba060500cf5 Mon Sep 17 00:00:00 2001
From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com>
Date: Fri, 13 Mar 2026 15:35:26 +0800
Subject: [PATCH 6/6] chore: revert ci.yml workflow_dispatch (not needed for
this PR)
---
.github/workflows/ci.yml | 1 -
1 file changed, 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 61e1aa737..de2359aca 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,7 +4,6 @@ on:
push:
branches: ["*"]
pull_request:
- workflow_dispatch:
jobs:
lint-build-test: