diff --git a/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift b/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift
new file mode 100644
index 000000000..d4b1cec73
--- /dev/null
+++ b/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift
@@ -0,0 +1,100 @@
+import AppKit
+import CodexBarCore
+import Foundation
+import SwiftUI
+
+struct LongCatProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .longcat
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { context in
+ context.store.sourceLabel(for: context.provider)
+ }
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.longcatUsageDataSource
+ _ = settings.longcatCookieSource
+ _ = settings.longcatManualCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .longcat(context.settings.longcatSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? {
+ context.settings.longcatUsageDataSource.rawValue
+ }
+
+ @MainActor
+ func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode {
+ switch context.settings.longcatUsageDataSource {
+ case .web: .web
+ case .auto, .api, .cli, .oauth: .auto
+ }
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.longcatCookieSource.rawValue },
+ set: { raw in
+ context.settings.longcatCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let options = ProviderCookieSourceUI.options(
+ allowsOff: true,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+
+ let subtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.longcatCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Automatic imports longcat.chat cookies from your browser.",
+ manual: "Paste a Cookie header copied from longcat.chat.",
+ off: "LongCat cookies are disabled.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "longcat-cookie-source",
+ title: "Cookie source",
+ subtitle: "Automatic imports longcat.chat cookies from your browser.",
+ dynamicSubtitle: subtitle,
+ binding: cookieBinding,
+ options: options,
+ isVisible: nil,
+ onChange: nil),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "longcat-cookie",
+ title: "",
+ subtitle: "",
+ kind: .secure,
+ placeholder: "Cookie: \u{2026}",
+ binding: context.stringBinding(\.longcatManualCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "longcat-open-console",
+ title: "Open Console",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://longcat.chat/platform/") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.longcatCookieSource == .manual },
+ onActivate: { context.settings.ensureLongCatCookieLoaded() }),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift b/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift
new file mode 100644
index 000000000..f0747b19d
--- /dev/null
+++ b/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift
@@ -0,0 +1,54 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var longcatUsageDataSource: ProviderSourceMode {
+ get { self.configSnapshot.providerConfig(for: .longcat)?.source ?? .auto }
+ set {
+ let source: ProviderSourceMode? = switch newValue {
+ case .auto: .auto
+ case .web: .web
+ case .api, .cli, .oauth: .auto
+ }
+ self.updateProviderConfig(provider: .longcat) { entry in
+ entry.source = source
+ }
+ self.logProviderModeChange(provider: .longcat, field: "usageSource", value: newValue.rawValue)
+ }
+ }
+
+ var longcatManualCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .longcat)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .longcat) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .longcat, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var longcatCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .longcat, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .longcat) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .longcat, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+
+ func ensureLongCatCookieLoaded() {}
+}
+
+extension SettingsStore {
+ func longcatSettingsSnapshot(tokenOverride: TokenAccountOverride?)
+ -> ProviderSettingsSnapshot.LongCatProviderSettings
+ {
+ self.ensureLongCatCookieLoaded()
+ return self.resolvedCookieSettings(
+ provider: .longcat,
+ configuredSource: self.longcatCookieSource,
+ configuredHeader: self.longcatManualCookieHeader,
+ tokenOverride: tokenOverride)
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 098e69325..16c3949cf 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -66,6 +66,7 @@ enum ProviderImplementationRegistry {
case .deepgram: DeepgramProviderImplementation()
case .poe: PoeProviderImplementation()
case .chutes: ChutesProviderImplementation()
+ case .longcat: LongCatProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-longcat.svg b/Sources/CodexBar/Resources/ProviderIcon-longcat.svg
new file mode 100644
index 000000000..dd1201c95
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-longcat.svg
@@ -0,0 +1,4 @@
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 6c4143188..9e7914303 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1086,7 +1086,7 @@ extension UsageStore {
case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .devin,
.vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao,
.abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock,
- .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes:
+ .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, .longcat:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift
index 4b866f2df..08da5cf7c 100644
--- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift
+++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.
enum CodexParserHash {
- static let value = "800a06dead603ea7"
+ static let value = "910b475f0fded3e5"
}
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index f0321295e..0ea4d8143 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -42,6 +42,9 @@ public enum LogCategories {
public static let kimiTokenStore = "kimi-token-store"
public static let kimiWeb = "kimi-web"
public static let kiro = "kiro"
+ public static let longcatAPI = "longcat-api"
+ public static let longcatCookie = "longcat-cookie"
+ public static let longcatWeb = "longcat-web"
public static let launchAtLogin = "launch-at-login"
public static let login = "login"
public static let logging = "logging"
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift
new file mode 100644
index 000000000..5f8d741c4
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+public enum LongCatAPIError: LocalizedError, Sendable, Equatable {
+ case missingCookies
+ case invalidSession
+ case invalidRequest(String)
+ case networkError(String)
+ case apiError(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCookies:
+ "LongCat session cookies are missing. Sign in at longcat.chat, or paste a cookie header."
+ case .invalidSession:
+ "LongCat session is invalid or expired. Please sign in again at longcat.chat."
+ case let .invalidRequest(message):
+ "Invalid request: \(message)"
+ case let .networkError(message):
+ "LongCat network error: \(message)"
+ case let .apiError(message):
+ "LongCat API error: \(message)"
+ case let .parseFailed(message):
+ "Failed to parse LongCat usage data: \(message)"
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
new file mode 100644
index 000000000..fe3e07006
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
@@ -0,0 +1,77 @@
+import Foundation
+
+public struct LongCatCookieOverride: Sendable {
+ /// Full `Cookie:` header value (e.g. `name=value; name2=value2`).
+ public let cookieHeader: String
+
+ public init(cookieHeader: String) {
+ self.cookieHeader = cookieHeader
+ }
+}
+
+public enum LongCatCookieHeader {
+ private static let log = CodexBarLog.logger(LogCategories.longcatCookie)
+ private static let headerPatterns: [String] = [
+ #"(?i)-H\s*'Cookie:\s*([^']+)'"#,
+ #"(?i)-H\s*"Cookie:\s*([^"]+)""#,
+ #"(?i)\bcookie:\s*'([^']+)'"#,
+ #"(?i)\bcookie:\s*"([^"]+)""#,
+ #"(?i)\bcookie:\s*([^\r\n]+)"#,
+ ]
+
+ public static func resolveCookieOverride(context: ProviderFetchContext) -> LongCatCookieOverride? {
+ // Off disables LongCat web auth entirely — including a lingering env cookie.
+ if context.settings?.longcat?.cookieSource == .off {
+ return nil
+ }
+
+ if let settings = context.settings?.longcat, settings.cookieSource == .manual {
+ if let manual = settings.manualCookieHeader, !manual.isEmpty {
+ return self.override(from: manual)
+ }
+ }
+
+ // Route env cookies through the settings reader so the lower-case
+ // `longcat_manual_cookie` alias and quote-trimming apply on the env path too.
+ if let envValue = LongCatSettingsReader.cookieHeader(environment: context.env),
+ let envHeader = self.override(from: envValue)
+ {
+ return envHeader
+ }
+
+ return nil
+ }
+
+ public static func override(from raw: String?) -> LongCatCookieOverride? {
+ guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
+ return nil
+ }
+
+ if let header = self.extractHeader(from: raw) {
+ return LongCatCookieOverride(cookieHeader: header)
+ }
+
+ // A bare `name=value; ...` string is itself a usable cookie header.
+ if raw.contains("=") {
+ return LongCatCookieOverride(cookieHeader: raw)
+ }
+
+ return nil
+ }
+
+ private static func extractHeader(from raw: String) -> String? {
+ for pattern in self.headerPatterns {
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { continue }
+ let range = NSRange(raw.startIndex..= 2,
+ let captureRange = Range(match.range(at: 1), in: raw)
+ else {
+ continue
+ }
+ let captured = String(raw[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines)
+ if !captured.isEmpty { return captured }
+ }
+ return nil
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift
new file mode 100644
index 000000000..1c011b68d
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift
@@ -0,0 +1,180 @@
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+
+public enum LongCatCookieImporter {
+ private static let log = CodexBarLog.logger(LogCategories.longcatCookie)
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["longcat.chat", "www.longcat.chat"]
+ private static let cookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.longcat]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public init(cookies: [HTTPCookie], sourceLabel: String) {
+ self.cookies = cookies
+ self.sourceLabel = sourceLabel
+ }
+
+ /// Full `Cookie:` header built from every longcat.chat cookie. LongCat's
+ /// console uses Meituan passport SSO; the exact auth cookie name is not
+ /// documented, so we forward the whole jar rather than keying on one name.
+ public var cookieHeader: String? {
+ guard !self.cookies.isEmpty else { return nil }
+ let header = HTTPCookie.requestHeaderFields(with: self.cookies)["Cookie"]
+ if let header, !header.isEmpty { return header }
+ return nil
+ }
+ }
+
+ public static func importSessions(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ var sessions: [SessionInfo] = []
+ let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
+ for browserSource in candidates {
+ do {
+ let perSource = try self.importSessions(from: browserSource, logger: logger)
+ sessions.append(contentsOf: perSource)
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ self.emit(
+ "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)",
+ logger: logger)
+ }
+ }
+
+ guard !sessions.isEmpty else {
+ throw LongCatCookieImportError.noCookies
+ }
+ return sessions
+ }
+
+ public static func importSessions(
+ from browserSource: Browser,
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let log: (String) -> Void = { msg in self.emit(msg, logger: logger) }
+ let sources = try Self.cookieClient.codexBarRecords(
+ matching: query,
+ in: browserSource,
+ logger: log)
+
+ var sessions: [SessionInfo] = []
+ let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id })
+ let sortedGroups = grouped.values.sorted { lhs, rhs in
+ self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs)
+ }
+
+ for group in sortedGroups where !group.isEmpty {
+ let label = self.mergedLabel(for: group)
+ let mergedRecords = self.mergeRecords(group)
+ guard !mergedRecords.isEmpty else { continue }
+ let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin)
+ guard !httpCookies.isEmpty else { continue }
+
+ log("Found \(httpCookies.count) longcat.chat cookie(s) in \(label)")
+ sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: label))
+ }
+ return sessions
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger)
+ guard let first = sessions.first else {
+ throw LongCatCookieImportError.noCookies
+ }
+ return first
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ do {
+ return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty
+ } catch {
+ return false
+ }
+ }
+
+ private static func emit(_ message: String, logger: ((String) -> Void)?) {
+ logger?("[longcat-cookie] \(message)")
+ self.log.debug(message)
+ }
+
+ private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String {
+ guard let base = sources.map(\.label).min() else {
+ return "Unknown"
+ }
+ if base.hasSuffix(" (Network)") {
+ return String(base.dropLast(" (Network)".count))
+ }
+ return base
+ }
+
+ private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] {
+ let sortedSources = sources.sorted { lhs, rhs in
+ self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind)
+ }
+ var mergedByKey: [String: BrowserCookieRecord] = [:]
+ for source in sortedSources {
+ for record in source.records {
+ let key = self.recordKey(record)
+ if let existing = mergedByKey[key] {
+ if self.shouldReplace(existing: existing, candidate: record) {
+ mergedByKey[key] = record
+ }
+ } else {
+ mergedByKey[key] = record
+ }
+ }
+ }
+ return Array(mergedByKey.values)
+ }
+
+ private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int {
+ switch kind {
+ case .network: 0
+ case .primary: 1
+ case .safari: 2
+ }
+ }
+
+ private static func recordKey(_ record: BrowserCookieRecord) -> String {
+ "\(record.name)|\(record.domain)|\(record.path)"
+ }
+
+ private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool {
+ switch (existing.expires, candidate.expires) {
+ case let (lhs?, rhs?):
+ rhs > lhs
+ case (nil, .some):
+ true
+ case (.some, nil):
+ false
+ case (nil, nil):
+ false
+ }
+ }
+}
+
+enum LongCatCookieImportError: LocalizedError {
+ case noCookies
+
+ var errorDescription: String? {
+ switch self {
+ case .noCookies:
+ "No LongCat session cookies found in browsers."
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift
new file mode 100644
index 000000000..7cc1213ee
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift
@@ -0,0 +1,81 @@
+import Foundation
+
+/// LongCat's web console wraps every response in a Meituan-style envelope:
+/// `{ "code": 0, "message": "...", "data": { ... } }`.
+///
+/// The exact `data` field names are not documented and cannot be derived from the
+/// minified front-end bundle, so extraction is intentionally lenient: we walk the
+/// decoded JSON trying a list of candidate keys and log the raw shape once so the
+/// mapping can be tightened against a real response. See `LongCatUsageFetcher`.
+enum LongCatEnvelope {
+ /// Returns the `data` payload if the envelope reports success, else throws.
+ static func unwrap(_ object: Any?) throws -> Any {
+ guard let dict = object as? [String: Any] else {
+ throw LongCatAPIError.parseFailed("response was not a JSON object")
+ }
+ // Meituan envelopes use code == 0 for success; some surfaces use 200.
+ if let code = LongCatJSON.int(dict["code"]), code != 0, code != 200 {
+ let message = LongCatJSON.string(dict["message"]) ?? LongCatJSON.string(dict["msg"]) ?? "code \(code)"
+ if code == 401 || code == 403 { throw LongCatAPIError.invalidSession }
+ throw LongCatAPIError.apiError(message)
+ }
+ return dict["data"] ?? dict
+ }
+}
+
+/// Tiny dynamic-JSON helper for lenient extraction by candidate key names.
+enum LongCatJSON {
+ static func int(_ value: Any?) -> Int? {
+ switch value {
+ case let v as Int: v
+ case let v as Double: Int(v)
+ case let v as String: Int(v) ?? Double(v).map(Int.init)
+ case let v as NSNumber: v.intValue
+ default: nil
+ }
+ }
+
+ static func double(_ value: Any?) -> Double? {
+ switch value {
+ case let v as Double: v
+ case let v as Int: Double(v)
+ case let v as String: Double(v)
+ case let v as NSNumber: v.doubleValue
+ default: nil
+ }
+ }
+
+ static func string(_ value: Any?) -> String? {
+ switch value {
+ case let v as String: v
+ case let v as NSNumber: v.stringValue
+ default: nil
+ }
+ }
+
+ static func object(_ value: Any?) -> [String: Any]? {
+ value as? [String: Any]
+ }
+
+ static func array(_ value: Any?) -> [[String: Any]]? {
+ if let arr = value as? [[String: Any]] { return arr }
+ if let arr = value as? [Any] { return arr.compactMap { $0 as? [String: Any] } }
+ return nil
+ }
+
+ /// First numeric value found under any of `keys`, searched at the top level
+ /// and one level deep (LongCat nests some figures under `quota`/`detail`).
+ static func firstNumber(in object: [String: Any], keys: [String]) -> Double? {
+ for key in keys {
+ if let value = double(object[key]) { return value }
+ }
+ for value in object.values {
+ if let nested = value as? [String: Any] {
+ for key in keys {
+ if let found = double(nested[key]) { return found }
+ }
+ }
+ }
+ return nil
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
new file mode 100644
index 000000000..6f3068b53
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
@@ -0,0 +1,107 @@
+import Foundation
+
+public enum LongCatProviderDescriptor {
+ public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
+
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .longcat,
+ metadata: ProviderMetadata(
+ id: .longcat,
+ displayName: "LongCat",
+ sessionLabel: "Quota",
+ weeklyLabel: "Fuel Pack",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show LongCat usage",
+ cliName: "longcat",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: ProviderBrowserCookieDefaults.longcatCookieImportOrder,
+ dashboardURL: "https://longcat.chat/platform/",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .longcat,
+ iconResourceName: "ProviderIcon-longcat",
+ color: ProviderColor(red: 255 / 255, green: 209 / 255, blue: 0 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "LongCat cost summary is not supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [LongCatWebFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "longcat",
+ aliases: ["long-cat", "lc"],
+ versionDetector: nil))
+ }
+}
+
+struct LongCatWebFetchStrategy: ProviderFetchStrategy {
+ let id: String = "longcat.web"
+ let kind: ProviderFetchKind = .web
+ private static let log = CodexBarLog.logger(LogCategories.longcatWeb)
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ if LongCatCookieHeader.resolveCookieOverride(context: context) != nil {
+ return true
+ }
+
+ #if os(macOS)
+ if Self.allowsBrowserImport(context: context) {
+ return LongCatCookieImporter.hasSession()
+ }
+ #endif
+
+ return false
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let cookieHeader = self.resolveCookieHeader(context: context) else {
+ throw LongCatAPIError.missingCookies
+ }
+
+ let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: cookieHeader)
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: "web")
+ }
+
+ func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool {
+ if case LongCatAPIError.missingCookies = error { return false }
+ if case LongCatAPIError.invalidSession = error { return false }
+ return true
+ }
+
+ private func resolveCookieHeader(context: ProviderFetchContext) -> String? {
+ if let override = LongCatCookieHeader.resolveCookieOverride(context: context) {
+ return override.cookieHeader
+ }
+
+ #if os(macOS)
+ if Self.allowsBrowserImport(context: context) {
+ if let session = try? LongCatCookieImporter.importSession(),
+ let header = session.cookieHeader
+ {
+ return header
+ }
+ }
+ #endif
+
+ return nil
+ }
+
+ /// Browser cookie/keychain import is only used for user-initiated app
+ /// refreshes in the Auto source. Manual must use the pasted header and Off
+ /// disables web auth, so neither should silently fall back to a browser
+ /// session.
+ static func allowsBrowserImport(context: ProviderFetchContext) -> Bool {
+ let source = context.settings?.longcat?.cookieSource
+ return context.runtime == .app &&
+ ProviderInteractionContext.current == .userInitiated &&
+ (source == nil || source == .auto)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift
new file mode 100644
index 000000000..9fcc78227
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+public enum LongCatSettingsReader {
+ /// Manual cookie header for the LongCat web console (longcat.chat).
+ public static func cookieHeader(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ let raw = environment["LONGCAT_MANUAL_COOKIE"] ?? environment["longcat_manual_cookie"]
+ return self.cleaned(raw)
+ }
+
+ /// LongCat OpenAI/Anthropic-compatible API key. Not used for usage (the public
+ /// API exposes no usage endpoint) but kept for parity and future signals.
+ public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ let raw = environment["LONGCAT_API_KEY"] ?? environment["longcat_api_key"]
+ return self.cleaned(raw)
+ }
+
+ private static func cleaned(_ raw: String?) -> String? {
+ guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value = String(value.dropFirst().dropLast())
+ }
+
+ value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift
new file mode 100644
index 000000000..91cce6ac2
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift
@@ -0,0 +1,158 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct LongCatUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.longcatAPI)
+ private static let host = "https://longcat.chat"
+
+ private static let userCurrentPath = "/api/v1/user-current"
+ private static let tokenUsagePath = "/api/lc-platform/v1/tokenUsage"
+ private static let pendingFuelPath = "/api/lc-platform/v1/pending-fuel-packages"
+
+ public static func fetchUsage(cookieHeader: String, now: Date = Date()) async throws -> LongCatUsageSnapshot {
+ // Account name. The user-current payload also carries a session token and
+ // phone number, so its body is never logged. This is the required probe:
+ // a Meituan envelope with HTTP 200 but code 401/403 surfaces as
+ // `.invalidSession` here (via unwrap) so expired cookies are reported
+ // rather than masked by an empty snapshot.
+ var account: [String: Any]?
+ if let data = try await self.get(self.userCurrentPath, cookieHeader: cookieHeader, required: true) {
+ account = try LongCatEnvelope.unwrap(self.json(data)) as? [String: Any]
+ }
+
+ var usage: [String: Any]?
+ if let data = try? await self.get(self.tokenUsagePath, cookieHeader: cookieHeader, required: false) {
+ self.logRawShape(self.tokenUsagePath, data)
+ usage = (try? LongCatEnvelope.unwrap(self.json(data))) as? [String: Any]
+ }
+
+ var fuel: [String: Any]?
+ if let data = try? await self.get(self.pendingFuelPath, cookieHeader: cookieHeader, required: false) {
+ self.logRawShape(self.pendingFuelPath, data)
+ fuel = (try? LongCatEnvelope.unwrap(self.json(data))) as? [String: Any]
+ }
+
+ return self.buildSnapshot(account: account, tokenUsage: usage, pendingFuel: fuel, now: now)
+ }
+
+ /// Pure extraction over the unwrapped `data` payloads. Field paths are locked
+ /// against captured live responses; see `LongCatProviderTests`.
+ static func buildSnapshot(
+ account: [String: Any]?,
+ tokenUsage: [String: Any]?,
+ pendingFuel: [String: Any]?,
+ now: Date = Date()) -> LongCatUsageSnapshot
+ {
+ var snapshot = LongCatUsageSnapshot(updatedAt: now)
+
+ if let account {
+ snapshot.accountName = LongCatJSON.string(account["name"]) ?? LongCatJSON.string(account["nickName"])
+ }
+
+ // Token quota: data.usage is the canonical aggregate; extData holds the
+ // per-model breakdown (LongCat-Flash-Lite, LongCat-2.0-Preview, ...).
+ if let tokenUsage {
+ let usage = LongCatJSON.object(tokenUsage["usage"]) ?? tokenUsage
+ snapshot.totalQuota = LongCatJSON.double(usage["totalToken"])
+ snapshot.usedQuota = LongCatJSON.double(usage["usedToken"])
+ snapshot.remainingQuota = LongCatJSON.double(usage["availableToken"])
+ }
+
+ if let pendingFuel {
+ self.applyFuelPackages(pendingFuel, to: &snapshot)
+ }
+
+ return snapshot
+ }
+
+ private static func applyFuelPackages(_ dict: [String: Any], to snapshot: inout LongCatUsageSnapshot) {
+ let total = LongCatJSON.double(dict["totalQuota"])
+ let packages = LongCatJSON.array(dict["list"]) ?? []
+
+ var remaining = 0.0
+ var sawRemaining = false
+ var nearestExpiry: Date?
+ for package in packages {
+ if let value = LongCatJSON.firstNumber(
+ in: package,
+ keys: ["availableToken", "remainToken", "remainQuota", "remainingQuota", "remain", "availableQuota"])
+ {
+ remaining += value
+ sawRemaining = true
+ }
+ if let expiry = self.parseDate(
+ package["expireTime"] ?? package["expiredTime"] ?? package["expireAt"]
+ ?? package["gmtExpire"] ?? package["expireDate"])
+ {
+ if nearestExpiry == nil || expiry < nearestExpiry! { nearestExpiry = expiry }
+ }
+ }
+
+ if let total, total > 0 {
+ snapshot.fuelPackTotal = total
+ snapshot.fuelPackRemaining = sawRemaining ? remaining : total
+ }
+ snapshot.nearestFuelExpiry = nearestExpiry
+ }
+
+ // MARK: - HTTP
+
+ private static func get(_ path: String, cookieHeader: String, required: Bool) async throws -> Data? {
+ guard let url = URL(string: self.host + path) else {
+ throw LongCatAPIError.invalidRequest("bad URL: \(path)")
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+ request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
+ request.setValue(self.host, forHTTPHeaderField: "Origin")
+ request.setValue("\(self.host)/platform/usage", forHTTPHeaderField: "Referer")
+ request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
+ let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
+ request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+
+ let response = try await ProviderHTTPClient.shared.response(for: request)
+ guard response.statusCode == 200 else {
+ if response.statusCode == 401 || response.statusCode == 403 {
+ throw LongCatAPIError.invalidSession
+ }
+ if required {
+ throw LongCatAPIError.apiError("HTTP \(response.statusCode) for \(path)")
+ }
+ Self.log.error("LongCat \(path) returned \(response.statusCode)")
+ return nil
+ }
+ return response.data
+ }
+
+ private static func json(_ data: Data) -> Any? {
+ try? JSONSerialization.jsonObject(with: data)
+ }
+
+ /// Logs the (non-sensitive) response shape to help future debugging. Never
+ /// called for user-current, whose body carries a session token + phone.
+ private static func logRawShape(_ path: String, _ data: Data) {
+ guard let body = String(data: data, encoding: .utf8) else { return }
+ Self.log.debug("LongCat \(path) raw: \(body.prefix(1200))")
+ }
+
+ private static func parseDate(_ value: Any?) -> Date? {
+ if let number = LongCatJSON.double(value) {
+ let seconds = number > 1_000_000_000_000 ? number / 1000 : number
+ if seconds > 1_000_000_000 { return Date(timeIntervalSince1970: seconds) }
+ }
+ if let string = LongCatJSON.string(value) {
+ let iso = ISO8601DateFormatter()
+ if let date = iso.date(from: string) { return date }
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ if let date = formatter.date(from: string) { return date }
+ }
+ return nil
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
new file mode 100644
index 000000000..e2452ed12
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
@@ -0,0 +1,81 @@
+import Foundation
+
+/// Parsed, Sendable view of the LongCat console quota model:
+/// 总额度 (total token quota) plus 加油包额度 (fuel packs, which expire).
+public struct LongCatUsageSnapshot: Sendable {
+ public var totalQuota: Double?
+ public var usedQuota: Double?
+ public var remainingQuota: Double?
+ public var fuelPackTotal: Double?
+ public var fuelPackRemaining: Double?
+ public var nearestFuelExpiry: Date?
+ public var accountName: String?
+ public var updatedAt: Date
+
+ public init(
+ totalQuota: Double? = nil,
+ usedQuota: Double? = nil,
+ remainingQuota: Double? = nil,
+ fuelPackTotal: Double? = nil,
+ fuelPackRemaining: Double? = nil,
+ nearestFuelExpiry: Date? = nil,
+ accountName: String? = nil,
+ updatedAt: Date = Date())
+ {
+ self.totalQuota = totalQuota
+ self.usedQuota = usedQuota
+ self.remainingQuota = remainingQuota
+ self.fuelPackTotal = fuelPackTotal
+ self.fuelPackRemaining = fuelPackRemaining
+ self.nearestFuelExpiry = nearestFuelExpiry
+ self.accountName = accountName
+ self.updatedAt = updatedAt
+ }
+}
+
+extension LongCatUsageSnapshot {
+ private func resolvedUsed(total: Double) -> Double {
+ if let used = usedQuota { return max(0, used) }
+ if let remaining = remainingQuota { return max(0, total - remaining) }
+ return 0
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ // Primary: overall token quota consumption (总额度).
+ var primary: RateWindow?
+ if let total = totalQuota, total > 0 {
+ let used = self.resolvedUsed(total: total)
+ primary = RateWindow(
+ usedPercent: min(100, used / total * 100),
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "\(Int(used))/\(Int(total))")
+ }
+
+ // Secondary: fuel-pack balance (加油包额度), with nearest expiry as reset.
+ var secondary: RateWindow?
+ if let total = fuelPackTotal, total > 0 {
+ let remaining = self.fuelPackRemaining ?? total
+ let used = max(0, total - remaining)
+ secondary = RateWindow(
+ usedPercent: min(100, used / total * 100),
+ windowMinutes: nil,
+ resetsAt: self.nearestFuelExpiry,
+ resetDescription: "Fuel pack: \(Int(remaining))/\(Int(total))")
+ }
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .longcat,
+ accountEmail: nil,
+ accountOrganization: self.accountName,
+ loginMethod: nil)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 99ee47ccd..12f9a80c4 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -106,6 +106,7 @@ public enum ProviderDescriptorRegistry {
.deepgram: DeepgramProviderDescriptor.descriptor,
.poe: PoeProviderDescriptor.descriptor,
.chutes: ChutesProviderDescriptor.descriptor,
+ .longcat: LongCatProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index 88cd14f15..5dd7d9d79 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -25,6 +25,7 @@ public struct ProviderSettingsSnapshot: Sendable {
copilot: CopilotProviderSettings? = nil,
kilo: KiloProviderSettings? = nil,
kimi: KimiProviderSettings? = nil,
+ longcat: LongCatProviderSettings? = nil,
augment: AugmentProviderSettings? = nil,
moonshot: MoonshotProviderSettings? = nil,
amp: AmpProviderSettings? = nil,
@@ -57,6 +58,7 @@ public struct ProviderSettingsSnapshot: Sendable {
copilot: copilot,
kilo: kilo,
kimi: kimi,
+ longcat: longcat,
augment: augment,
moonshot: moonshot,
amp: amp,
@@ -273,6 +275,16 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct LongCatProviderSettings: ProviderCookieSettings {
+ public let cookieSource: ProviderCookieSource
+ public let manualCookieHeader: String?
+
+ public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) {
+ self.cookieSource = cookieSource
+ self.manualCookieHeader = manualCookieHeader
+ }
+ }
+
public struct AugmentProviderSettings: ProviderCookieSettings {
public let cookieSource: ProviderCookieSource
public let manualCookieHeader: String?
@@ -442,6 +454,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let copilot: CopilotProviderSettings?
public let kilo: KiloProviderSettings?
public let kimi: KimiProviderSettings?
+ public let longcat: LongCatProviderSettings?
public let augment: AugmentProviderSettings?
public let moonshot: MoonshotProviderSettings?
public let amp: AmpProviderSettings?
@@ -478,6 +491,7 @@ public struct ProviderSettingsSnapshot: Sendable {
copilot: CopilotProviderSettings?,
kilo: KiloProviderSettings?,
kimi: KimiProviderSettings?,
+ longcat: LongCatProviderSettings? = nil,
augment: AugmentProviderSettings?,
moonshot: MoonshotProviderSettings? = nil,
amp: AmpProviderSettings?,
@@ -509,6 +523,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.copilot = copilot
self.kilo = kilo
self.kimi = kimi
+ self.longcat = longcat
self.augment = augment
self.moonshot = moonshot
self.amp = amp
@@ -541,6 +556,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case copilot(ProviderSettingsSnapshot.CopilotProviderSettings)
case kilo(ProviderSettingsSnapshot.KiloProviderSettings)
case kimi(ProviderSettingsSnapshot.KimiProviderSettings)
+ case longcat(ProviderSettingsSnapshot.LongCatProviderSettings)
case augment(ProviderSettingsSnapshot.AugmentProviderSettings)
case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings)
case amp(ProviderSettingsSnapshot.AmpProviderSettings)
@@ -574,6 +590,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var copilot: ProviderSettingsSnapshot.CopilotProviderSettings?
public var kilo: ProviderSettingsSnapshot.KiloProviderSettings?
public var kimi: ProviderSettingsSnapshot.KimiProviderSettings?
+ public var longcat: ProviderSettingsSnapshot.LongCatProviderSettings?
public var augment: ProviderSettingsSnapshot.AugmentProviderSettings?
public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings?
public var amp: ProviderSettingsSnapshot.AmpProviderSettings?
@@ -611,6 +628,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .copilot(value): self.copilot = value
case let .kilo(value): self.kilo = value
case let .kimi(value): self.kimi = value
+ case let .longcat(value): self.longcat = value
case let .augment(value): self.augment = value
case let .moonshot(value): self.moonshot = value
case let .amp(value): self.amp = value
@@ -646,6 +664,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
copilot: self.copilot,
kilo: self.kilo,
kimi: self.kimi,
+ longcat: self.longcat,
augment: self.augment,
moonshot: self.moonshot,
amp: self.amp,
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 005a6d2e4..2d8829d7e 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -56,6 +56,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case deepgram
case poe
case chutes
+ case longcat
}
// swiftformat:enable sortDeclarations
@@ -112,6 +113,7 @@ public enum IconStyle: String, Sendable, CaseIterable {
case deepgram
case poe
case chutes
+ case longcat
case combined
}
@@ -260,4 +262,13 @@ public enum ProviderBrowserCookieDefaults {
nil
#endif
}
+
+ /// LongCat Auto imports only from Chrome by default to avoid prompting unrelated browser keychains.
+ public static var longcatCookieImportOrder: BrowserCookieImportOrder? {
+ #if os(macOS)
+ [.chrome]
+ #else
+ nil
+ #endif
+ }
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index aff14a6d9..4aeed7b54 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -452,7 +452,7 @@ enum CostUsageScanner {
.copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp,
.ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus,
.mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .stepfun, .bedrock, .grok,
- .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes:
+ .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .longcat:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 923690bb1..5002780f4 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -106,6 +106,7 @@ enum ProviderChoice: String, AppEnum {
case .deepgram: return nil // Deepgram not yet supported in widgets
case .poe: return nil // Poe not yet supported in widgets
case .chutes: return nil // Chutes not yet supported in widgets
+ case .longcat: return nil // LongCat not yet supported in widgets
case .zed: return nil // Zed not yet supported in widgets
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 10fe0183f..428d4eb2d 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -315,6 +315,7 @@ private struct ProviderSwitchChip: View {
case .deepgram: "Deepgram"
case .poe: "Poe"
case .chutes: "Chutes"
+ case .longcat: "LongCat"
case .zed: "Zed"
}
}
@@ -857,6 +858,8 @@ enum WidgetColors {
Color(red: 0.15, green: 0.68, blue: 0.38)
case .chutes:
Color(red: 24 / 255, green: 160 / 255, blue: 88 / 255)
+ case .longcat:
+ Color(red: 255 / 255, green: 209 / 255, blue: 0 / 255)
case .zed:
Color(red: 64 / 255, green: 156 / 255, blue: 255 / 255)
}
diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift
index c8cf68d48..d43940465 100644
--- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift
+++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift
@@ -69,5 +69,11 @@ struct BrowserCookieOrderStatusStringTests {
#expect(ProviderDefaults.metadata[.copilot]?.browserCookieOrder == [.chrome])
#expect(ProviderBrowserCookieDefaults.copilotCookieImportOrder == [.chrome])
}
+
+ @Test
+ func `longcat cookie imports default to chrome only`() {
+ #expect(ProviderDefaults.metadata[.longcat]?.browserCookieOrder == [.chrome])
+ #expect(ProviderBrowserCookieDefaults.longcatCookieImportOrder == [.chrome])
+ }
#endif
}
diff --git a/Tests/CodexBarTests/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift
new file mode 100644
index 000000000..0f0780e75
--- /dev/null
+++ b/Tests/CodexBarTests/LongCatProviderTests.swift
@@ -0,0 +1,193 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct LongCatProviderTests {
+ // MARK: - Settings reader
+
+ @Test
+ func `reads LONGCAT_MANUAL_COOKIE`() {
+ let env = ["LONGCAT_MANUAL_COOKIE": "passport_token=abc; uid=42"]
+ #expect(LongCatSettingsReader.cookieHeader(environment: env) == "passport_token=abc; uid=42")
+ }
+
+ @Test
+ func `reads LONGCAT_API_KEY and trims quotes`() {
+ #expect(LongCatSettingsReader.apiKey(environment: ["LONGCAT_API_KEY": " \"ak_x\" "]) == "ak_x")
+ }
+
+ @Test
+ func `missing env returns nil`() {
+ #expect(LongCatSettingsReader.cookieHeader(environment: [:]) == nil)
+ #expect(LongCatSettingsReader.apiKey(environment: [:]) == nil)
+ }
+
+ @Test
+ func `cookieHeader reads lowercase alias and trims quotes`() {
+ // The env path routes through this reader, so the lower-case alias and
+ // quote-trimming must apply (regression for the env-bypass fix).
+ #expect(LongCatSettingsReader.cookieHeader(environment: ["longcat_manual_cookie": "'a=b; c=d'"]) == "a=b; c=d")
+ }
+
+ // MARK: - Cookie header override
+
+ @Test
+ func `override accepts bare cookie pair string`() {
+ let override = LongCatCookieHeader.override(from: "passport_token=abc; uid=42")
+ #expect(override?.cookieHeader == "passport_token=abc; uid=42")
+ }
+
+ @Test
+ func `override extracts from a curl Cookie header`() {
+ let raw = "curl 'https://longcat.chat/api/v1/user-current' -H 'Cookie: passport_token=abc; uid=42'"
+ let override = LongCatCookieHeader.override(from: raw)
+ #expect(override?.cookieHeader == "passport_token=abc; uid=42")
+ }
+
+ @Test
+ func `override rejects a token-less string`() {
+ #expect(LongCatCookieHeader.override(from: "not a cookie") == nil)
+ #expect(LongCatCookieHeader.override(from: " ") == nil)
+ }
+
+ // MARK: - Snapshot mapping
+
+ @Test
+ func `total quota maps to primary used percent`() {
+ let snapshot = LongCatUsageSnapshot(totalQuota: 1000, usedQuota: 250)
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.identity?.providerID == .longcat)
+ #expect(abs((usage.primary?.usedPercent ?? 0) - 25) < 0.001)
+ }
+
+ @Test
+ func `remaining quota infers used when used is absent`() {
+ let snapshot = LongCatUsageSnapshot(totalQuota: 1000, remainingQuota: 400)
+ #expect(abs((snapshot.toUsageSnapshot().primary?.usedPercent ?? 0) - 60) < 0.001)
+ }
+
+ @Test
+ func `missing quota data omits primary window`() {
+ let usage = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200).toUsageSnapshot()
+ #expect(usage.primary == nil)
+ #expect(usage.secondary != nil)
+ }
+
+ @Test
+ func `fuel pack populates secondary window`() {
+ let snapshot = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200)
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.secondary != nil)
+ #expect(abs((usage.secondary?.usedPercent ?? 0) - 60) < 0.001)
+ }
+
+ // MARK: - buildSnapshot against captured live response shapes
+
+ private func object(_ json: String) throws -> [String: Any] {
+ let parsed = try JSONSerialization.jsonObject(with: Data(json.utf8))
+ return try #require(parsed as? [String: Any])
+ }
+
+ @Test
+ func `buildSnapshot maps live tokenUsage and account fields`() throws {
+ // Shapes captured from longcat.chat console (values neutralised).
+ let account = try self.object(#"{"userId":1,"name":"LongCat User","phone":"x","token":"secret"}"#)
+ let tokenUsage = try self.object(#"""
+ {"usage":{"totalToken":500000,"usedToken":120000,"availableToken":380000,"freeAvailableToken":380000},
+ "extData":{"LongCat-Flash-Lite":{"totalToken":50000000,"usedToken":0}}}
+ """#)
+ let fuel = try self.object(#"{"totalQuota":0,"list":[]}"#)
+
+ let snapshot = LongCatUsageFetcher.buildSnapshot(account: account, tokenUsage: tokenUsage, pendingFuel: fuel)
+ #expect(snapshot.accountName == "LongCat User")
+ #expect(snapshot.totalQuota == 500_000)
+ #expect(snapshot.usedQuota == 120_000)
+ #expect(snapshot.remainingQuota == 380_000)
+ #expect(snapshot.fuelPackTotal == nil) // empty fuel list
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(abs((usage.primary?.usedPercent ?? 0) - 24) < 0.001)
+ #expect(usage.secondary == nil)
+ }
+
+ @Test
+ func `buildSnapshot sums active fuel packages`() throws {
+ let fuel = try self.object(#"""
+ {"totalQuota":1000,"list":[{"availableToken":600,"expireTime":1750000000000},
+ {"availableToken":150,"expireTime":1760000000000}]}
+ """#)
+ let snapshot = LongCatUsageFetcher.buildSnapshot(account: nil, tokenUsage: nil, pendingFuel: fuel)
+ #expect(snapshot.fuelPackTotal == 1000)
+ #expect(snapshot.fuelPackRemaining == 750)
+ #expect(snapshot.nearestFuelExpiry != nil)
+ #expect(snapshot.toUsageSnapshot().primary == nil)
+ }
+
+ // MARK: - Envelope
+
+ @Test
+ func `envelope surfaces invalid session on auth code`() {
+ #expect(throws: LongCatAPIError.invalidSession) {
+ try LongCatEnvelope.unwrap(["code": 401, "message": "unauthorized"])
+ }
+ }
+
+ @Test
+ func `envelope unwraps data on success`() throws {
+ let data = try LongCatEnvelope.unwrap(["code": 0, "data": ["x": 1]]) as? [String: Any]
+ #expect(data?["x"] as? Int == 1)
+ }
+
+ // MARK: - Cookie source semantics
+
+ private func context(
+ env: [String: String],
+ cookieSource: ProviderCookieSource,
+ runtime: ProviderRuntime = .app) -> ProviderFetchContext
+ {
+ let browserDetection = BrowserDetection(cacheTTL: 0)
+ return ProviderFetchContext(
+ runtime: runtime,
+ sourceMode: .web,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: env,
+ settings: ProviderSettingsSnapshot.make(
+ longcat: .init(cookieSource: cookieSource, manualCookieHeader: nil)),
+ fetcher: UsageFetcher(environment: [:]),
+ claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
+ browserDetection: browserDetection)
+ }
+
+ @Test
+ func `off source disables env cookie override`() {
+ let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .off)
+ #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx) == nil)
+ }
+
+ @Test
+ func `auto source allows env cookie override`() {
+ let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .auto)
+ #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx)?.cookieHeader == "a=b")
+ }
+
+ @Test
+ func `browser import is user initiated app auto only`() {
+ let appAuto = self.context(env: [:], cookieSource: .auto)
+ let cliAuto = self.context(env: [:], cookieSource: .auto, runtime: .cli)
+ let appManual = self.context(env: [:], cookieSource: .manual)
+ let appOff = self.context(env: [:], cookieSource: .off)
+
+ #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto) == false)
+ #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false)
+
+ ProviderInteractionContext.$current.withValue(.userInitiated) {
+ #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto))
+ #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false)
+ #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appManual) == false)
+ #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appOff) == false)
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
index 2d00a9ca4..dfd73d5ce 100644
--- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift
+++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
@@ -29,6 +29,7 @@ struct ProviderIconResourcesTests {
"commandcode",
"t3chat",
"kimi",
+ "longcat",
"bedrock",
"elevenlabs",
"groq",
diff --git a/docs/configuration.md b/docs/configuration.md
index 6a7577496..b05674520 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -167,7 +167,7 @@ and never paste real cookie values or readable DevTools screenshots into public
## Provider IDs
Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`):
-`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`.
+`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `longcat`.
## Ordering
The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering.