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 @@ +Doubao 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 @@ +Qwen 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: