From f4b31523a1e2a7179788cb9b06c1cfc5cef78291 Mon Sep 17 00:00:00 2001
From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com>
Date: Sun, 21 Jun 2026 22:22:08 +0800
Subject: [PATCH 1/6] feat: add LongCat usage provider
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cookie-based web provider for LongCat (Meituan) that surfaces console
token quota (总额度) and fuel-pack balance (加油包) by reading the
longcat.chat platform session, mirroring the Kimi/MiniMax cookie pattern.
Field mapping is locked against captured live responses:
- GET /api/v1/user-current -> data.name
- GET /api/lc-platform/v1/tokenUsage -> data.usage.{total,used,available}Token
- GET /api/lc-platform/v1/pending-fuel-packages -> data.totalQuota + data.list[]
The public API key path exposes no usage endpoint, so usage is read from
the web console session (all longcat.chat cookies are forwarded since the
Meituan passport cookie name is undocumented). The user-current body is
never logged (it carries a session token + phone).
Wires .longcat into the provider/icon enums, descriptor registry, settings
snapshot/builder, implementation registry, logging, widget, cost-usage and
debug switches; adds brand icon, docs provider-id list, CHANGELOG entry and
unit tests covering the live response shapes.
---
CHANGELOG.md | 3 +
.../LongCatProviderImplementation.swift | 100 ++++++++++
.../LongCat/LongCatSettingsStore.swift | 54 ++++++
.../ProviderImplementationRegistry.swift | 1 +
.../Resources/ProviderIcon-longcat.svg | 4 +
Sources/CodexBar/UsageStore.swift | 2 +-
.../Generated/CodexParserHash.generated.swift | 2 +-
.../CodexBarCore/Logging/LogCategories.swift | 3 +
.../Providers/LongCat/LongCatAPIError.swift | 27 +++
.../LongCat/LongCatCookieHeader.swift | 68 +++++++
.../LongCat/LongCatCookieImporter.swift | 180 ++++++++++++++++++
.../Providers/LongCat/LongCatModels.swift | 81 ++++++++
.../LongCat/LongCatProviderDescriptor.swift | 96 ++++++++++
.../LongCat/LongCatSettingsReader.swift | 31 +++
.../LongCat/LongCatUsageFetcher.swift | 156 +++++++++++++++
.../LongCat/LongCatUsageSnapshot.swift | 102 ++++++++++
.../Providers/ProviderDescriptor.swift | 1 +
.../Providers/ProviderSettingsSnapshot.swift | 19 ++
.../CodexBarCore/Providers/Providers.swift | 2 +
.../Vendored/CostUsage/CostUsageScanner.swift | 2 +-
.../CodexBarWidgetProvider.swift | 1 +
.../CodexBarWidget/CodexBarWidgetViews.swift | 3 +
.../CodexBarTests/LongCatProviderTests.swift | 116 +++++++++++
.../ProviderIconResourcesTests.swift | 1 +
docs/configuration.md | 2 +-
25 files changed, 1053 insertions(+), 4 deletions(-)
create mode 100644 Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift
create mode 100644 Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift
create mode 100644 Sources/CodexBar/Resources/ProviderIcon-longcat.svg
create mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift
create mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
create mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift
create mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift
create mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
create mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift
create mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift
create mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
create mode 100644 Tests/CodexBarTests/LongCatProviderTests.swift
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33e76f7a93..c59daa09ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
## 0.37.3 — Unreleased
+### Added
+- LongCat: show console token quota (总额度) and fuel-pack balance (加油包) from a browser session. Thanks @LeoLin990405!
+
## 0.37.2 — 2026-06-22
### Added
diff --git a/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift b/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift
new file mode 100644
index 0000000000..d4b1cec73f
--- /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 0000000000..f0747b19df
--- /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 098e69325e..16c3949cfd 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 0000000000..dd1201c95e
--- /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 6c41431886..9e79143031 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 4b866f2df3..08da5cf7ca 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 f0321295ec..0ea4d81435 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 0000000000..5f8d741c4d
--- /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 0000000000..a908ebf13d
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
@@ -0,0 +1,68 @@
+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? {
+ if let settings = context.settings?.longcat, settings.cookieSource == .manual {
+ if let manual = settings.manualCookieHeader, !manual.isEmpty {
+ return self.override(from: manual)
+ }
+ }
+
+ if let envHeader = self.override(from: context.env["LONGCAT_MANUAL_COOKIE"]) {
+ 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 0000000000..1c011b68dd
--- /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 0000000000..7cc1213ee0
--- /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 0000000000..cd8f2170df
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
@@ -0,0 +1,96 @@
+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: nil,
+ 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 context.settings?.longcat?.cookieSource != .off {
+ 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 context.settings?.longcat?.cookieSource != .off {
+ if let session = try? LongCatCookieImporter.importSession(),
+ let header = session.cookieHeader
+ {
+ return header
+ }
+ }
+ #endif
+
+ return nil
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift
new file mode 100644
index 0000000000..9fcc78227a
--- /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 0000000000..f600df55da
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift
@@ -0,0 +1,156 @@
+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. Failure here is non-fatal.
+ 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"])
+ snapshot.freeQuota = LongCatJSON.double(usage["freeAvailableToken"])
+ }
+
+ 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 0000000000..2cebef7907
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
@@ -0,0 +1,102 @@
+import Foundation
+
+/// Parsed, Sendable view of the LongCat console quota model:
+/// 总额度 (total) = 初始额度 (free, refreshed daily) + 加油包额度 (fuel packs, expiring),
+/// plus today's token usage from `tokenUsage`.
+public struct LongCatUsageSnapshot: Sendable {
+ public var totalQuota: Double?
+ public var freeQuota: Double?
+ public var fuelPackTotal: Double?
+ public var fuelPackRemaining: Double?
+ public var usedQuota: Double?
+ public var remainingQuota: Double?
+ public var todayTokens: Double?
+ public var nearestFuelExpiry: Date?
+ public var accountName: String?
+ public var updatedAt: Date
+
+ public init(
+ totalQuota: Double? = nil,
+ freeQuota: Double? = nil,
+ fuelPackTotal: Double? = nil,
+ fuelPackRemaining: Double? = nil,
+ usedQuota: Double? = nil,
+ remainingQuota: Double? = nil,
+ todayTokens: Double? = nil,
+ nearestFuelExpiry: Date? = nil,
+ accountName: String? = nil,
+ updatedAt: Date = Date())
+ {
+ self.totalQuota = totalQuota
+ self.freeQuota = freeQuota
+ self.fuelPackTotal = fuelPackTotal
+ self.fuelPackRemaining = fuelPackRemaining
+ self.usedQuota = usedQuota
+ self.remainingQuota = remainingQuota
+ self.todayTokens = todayTokens
+ 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 quota consumption (总额度).
+ var primary = RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "No LongCat quota data")
+ 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))")
+ }
+
+ // Tertiary: informational today-token count.
+ var tertiary: RateWindow?
+ if let today = todayTokens {
+ tertiary = RateWindow(
+ usedPercent: 0,
+ windowMinutes: 1440,
+ resetsAt: nil,
+ resetDescription: "Today: \(Int(today)) tokens")
+ }
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .longcat,
+ accountEmail: nil,
+ accountOrganization: self.accountName,
+ loginMethod: nil)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: tertiary,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 99ee47ccd1..12f9a80c42 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 88cd14f15d..5dd7d9d797 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 005a6d2e4d..7696193088 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
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index aff14a6d9a..4aeed7b54f 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 923690bb13..5002780f45 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 10fe0183fe..428d4eb2de 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/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift
new file mode 100644
index 0000000000..285ec49ede
--- /dev/null
+++ b/Tests/CodexBarTests/LongCatProviderTests.swift
@@ -0,0 +1,116 @@
+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)
+ }
+
+ // 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 `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)
+ }
+
+ @Test
+ func `today tokens populate tertiary window`() {
+ let usage = LongCatUsageSnapshot(todayTokens: 12345).toUsageSnapshot()
+ #expect(usage.tertiary != nil)
+ }
+
+ // 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)
+ }
+}
diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
index 2d00a9ca44..dfd73d5cec 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 6a75774965..b056745201 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.
From ba31d955e407f484ca5525c3d8d76917cc3a58ff Mon Sep 17 00:00:00 2001
From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com>
Date: Tue, 23 Jun 2026 11:52:45 +0800
Subject: [PATCH 2/6] fix(longcat): surface invalid-session and drop unused
today-token path
Addresses Codex review on #1697:
- user-current now propagates envelope auth failures (HTTP 200 + code
401/403 -> .invalidSession) instead of swallowing them with try?, so
expired cookies prompt re-auth rather than reporting an empty snapshot.
- Remove the never-assigned todayTokens / freeQuota fields and the
unreachable tertiary 'Today' window; LongCat's tokenUsage is a quota
snapshot with no per-day figure.
- Add envelope unit tests (invalid-session + success unwrap).
---
.../LongCat/LongCatUsageFetcher.swift | 8 +++--
.../LongCat/LongCatUsageSnapshot.swift | 35 +++++--------------
.../CodexBarTests/LongCatProviderTests.swift | 21 +++++++----
3 files changed, 29 insertions(+), 35 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift
index f600df55da..91cce6ac2b 100644
--- a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift
@@ -14,10 +14,13 @@ public struct LongCatUsageFetcher: Sendable {
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. Failure here is non-fatal.
+ // 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]
+ account = try LongCatEnvelope.unwrap(self.json(data)) as? [String: Any]
}
var usage: [String: Any]?
@@ -56,7 +59,6 @@ public struct LongCatUsageFetcher: Sendable {
snapshot.totalQuota = LongCatJSON.double(usage["totalToken"])
snapshot.usedQuota = LongCatJSON.double(usage["usedToken"])
snapshot.remainingQuota = LongCatJSON.double(usage["availableToken"])
- snapshot.freeQuota = LongCatJSON.double(usage["freeAvailableToken"])
}
if let pendingFuel {
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
index 2cebef7907..0e8285ea09 100644
--- a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
@@ -1,39 +1,32 @@
import Foundation
/// Parsed, Sendable view of the LongCat console quota model:
-/// 总额度 (total) = 初始额度 (free, refreshed daily) + 加油包额度 (fuel packs, expiring),
-/// plus today's token usage from `tokenUsage`.
+/// 总额度 (total token quota) plus 加油包额度 (fuel packs, which expire).
public struct LongCatUsageSnapshot: Sendable {
public var totalQuota: Double?
- public var freeQuota: Double?
- public var fuelPackTotal: Double?
- public var fuelPackRemaining: Double?
public var usedQuota: Double?
public var remainingQuota: Double?
- public var todayTokens: 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,
- freeQuota: Double? = nil,
- fuelPackTotal: Double? = nil,
- fuelPackRemaining: Double? = nil,
usedQuota: Double? = nil,
remainingQuota: Double? = nil,
- todayTokens: Double? = nil,
+ fuelPackTotal: Double? = nil,
+ fuelPackRemaining: Double? = nil,
nearestFuelExpiry: Date? = nil,
accountName: String? = nil,
updatedAt: Date = Date())
{
self.totalQuota = totalQuota
- self.freeQuota = freeQuota
- self.fuelPackTotal = fuelPackTotal
- self.fuelPackRemaining = fuelPackRemaining
self.usedQuota = usedQuota
self.remainingQuota = remainingQuota
- self.todayTokens = todayTokens
+ self.fuelPackTotal = fuelPackTotal
+ self.fuelPackRemaining = fuelPackRemaining
self.nearestFuelExpiry = nearestFuelExpiry
self.accountName = accountName
self.updatedAt = updatedAt
@@ -48,7 +41,7 @@ extension LongCatUsageSnapshot {
}
public func toUsageSnapshot() -> UsageSnapshot {
- // Primary: overall quota consumption (总额度).
+ // Primary: overall token quota consumption (总额度).
var primary = RateWindow(
usedPercent: 0,
windowMinutes: nil,
@@ -75,16 +68,6 @@ extension LongCatUsageSnapshot {
resetDescription: "Fuel pack: \(Int(remaining))/\(Int(total))")
}
- // Tertiary: informational today-token count.
- var tertiary: RateWindow?
- if let today = todayTokens {
- tertiary = RateWindow(
- usedPercent: 0,
- windowMinutes: 1440,
- resetsAt: nil,
- resetDescription: "Today: \(Int(today)) tokens")
- }
-
let identity = ProviderIdentitySnapshot(
providerID: .longcat,
accountEmail: nil,
@@ -94,7 +77,7 @@ extension LongCatUsageSnapshot {
return UsageSnapshot(
primary: primary,
secondary: secondary,
- tertiary: tertiary,
+ tertiary: nil,
providerCost: nil,
updatedAt: self.updatedAt,
identity: identity)
diff --git a/Tests/CodexBarTests/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift
index 285ec49ede..d08507bb50 100644
--- a/Tests/CodexBarTests/LongCatProviderTests.swift
+++ b/Tests/CodexBarTests/LongCatProviderTests.swift
@@ -67,12 +67,6 @@ struct LongCatProviderTests {
#expect(abs((usage.secondary?.usedPercent ?? 0) - 60) < 0.001)
}
- @Test
- func `today tokens populate tertiary window`() {
- let usage = LongCatUsageSnapshot(todayTokens: 12345).toUsageSnapshot()
- #expect(usage.tertiary != nil)
- }
-
// MARK: - buildSnapshot against captured live response shapes
private func object(_ json: String) throws -> [String: Any] {
@@ -113,4 +107,19 @@ struct LongCatProviderTests {
#expect(snapshot.fuelPackRemaining == 750)
#expect(snapshot.nearestFuelExpiry != 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)
+ }
}
From 4110e2ae18194a4f5a41e84a2b8c133055f52f67 Mon Sep 17 00:00:00 2001
From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com>
Date: Tue, 23 Jun 2026 12:48:14 +0800
Subject: [PATCH 3/6] fix(longcat): route env cookie through settings reader
Addresses Codex re-review on #1697: resolveCookieOverride read
context.env["LONGCAT_MANUAL_COOKIE"] directly, bypassing
LongCatSettingsReader.cookieHeader(), so the lower-case
longcat_manual_cookie alias and quote-trimming never reached the env
fetch path for CLI/daemon users. Route the env value through the reader
first. (The P3 changelog 'today's token usage' wording was already
dropped during the rebase onto main.)
---
.../Providers/LongCat/LongCatCookieHeader.swift | 6 +++++-
Tests/CodexBarTests/LongCatProviderTests.swift | 7 +++++++
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
index a908ebf13d..965172973b 100644
--- a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
@@ -26,7 +26,11 @@ public enum LongCatCookieHeader {
}
}
- if let envHeader = self.override(from: context.env["LONGCAT_MANUAL_COOKIE"]) {
+ // 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
}
diff --git a/Tests/CodexBarTests/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift
index d08507bb50..1e65d7a035 100644
--- a/Tests/CodexBarTests/LongCatProviderTests.swift
+++ b/Tests/CodexBarTests/LongCatProviderTests.swift
@@ -22,6 +22,13 @@ struct LongCatProviderTests {
#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
From 670a9d25df3a28e61837f7babc45700966b73b57 Mon Sep 17 00:00:00 2001
From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com>
Date: Tue, 23 Jun 2026 14:14:09 +0800
Subject: [PATCH 4/6] fix(longcat): honor cookie source Off/Manual in web
strategy
Addresses Codex re-review on #1697 (2 P2):
- Off now fully disables web auth: resolveCookieOverride returns nil when
cookieSource is .off, so a lingering LONGCAT_MANUAL_COOKIE env value can
no longer keep the web strategy available.
- Browser cookie/keychain import is gated to the Auto source only; Manual
no longer silently falls back to a browser session when the pasted
header is missing/invalid (it surfaces as unavailable instead).
- Add regression tests for the Off/Auto env-override gating.
---
.../LongCat/LongCatCookieHeader.swift | 5 +++
.../LongCat/LongCatProviderDescriptor.swift | 12 +++++--
.../CodexBarTests/LongCatProviderTests.swift | 34 +++++++++++++++++++
3 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
index 965172973b..fe3e07006e 100644
--- a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
@@ -20,6 +20,11 @@ public enum LongCatCookieHeader {
]
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)
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
index cd8f2170df..08d7be88f2 100644
--- a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
@@ -51,7 +51,7 @@ struct LongCatWebFetchStrategy: ProviderFetchStrategy {
}
#if os(macOS)
- if context.settings?.longcat?.cookieSource != .off {
+ if self.allowsBrowserImport(context) {
return LongCatCookieImporter.hasSession()
}
#endif
@@ -82,7 +82,7 @@ struct LongCatWebFetchStrategy: ProviderFetchStrategy {
}
#if os(macOS)
- if context.settings?.longcat?.cookieSource != .off {
+ if self.allowsBrowserImport(context) {
if let session = try? LongCatCookieImporter.importSession(),
let header = session.cookieHeader
{
@@ -93,4 +93,12 @@ struct LongCatWebFetchStrategy: ProviderFetchStrategy {
return nil
}
+
+ /// Browser cookie/keychain import is only used for the Auto source (the
+ /// default). Manual must use the pasted header and Off disables web auth, so
+ /// neither should silently fall back to a browser session.
+ private func allowsBrowserImport(_ context: ProviderFetchContext) -> Bool {
+ let source = context.settings?.longcat?.cookieSource
+ return source == nil || source == .auto
+ }
}
diff --git a/Tests/CodexBarTests/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift
index 1e65d7a035..e7ba648d35 100644
--- a/Tests/CodexBarTests/LongCatProviderTests.swift
+++ b/Tests/CodexBarTests/LongCatProviderTests.swift
@@ -129,4 +129,38 @@ struct LongCatProviderTests {
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) -> ProviderFetchContext
+ {
+ let browserDetection = BrowserDetection(cacheTTL: 0)
+ return ProviderFetchContext(
+ runtime: .app,
+ 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")
+ }
}
From 853910e75b2c8cd61dd336839abe77ceacc526e9 Mon Sep 17 00:00:00 2001
From: LeoLin
Date: Wed, 24 Jun 2026 09:41:31 +0800
Subject: [PATCH 5/6] fix(longcat): gate browser cookie import
---
CHANGELOG.md | 3 ---
.../LongCat/LongCatProviderDescriptor.swift | 17 ++++++++------
.../CodexBarTests/LongCatProviderTests.swift | 23 +++++++++++++++++--
3 files changed, 31 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c59daa09ab..33e76f7a93 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,9 +2,6 @@
## 0.37.3 — Unreleased
-### Added
-- LongCat: show console token quota (总额度) and fuel-pack balance (加油包) from a browser session. Thanks @LeoLin990405!
-
## 0.37.2 — 2026-06-22
### Added
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
index 08d7be88f2..db9b184ec2 100644
--- a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
@@ -51,7 +51,7 @@ struct LongCatWebFetchStrategy: ProviderFetchStrategy {
}
#if os(macOS)
- if self.allowsBrowserImport(context) {
+ if Self.allowsBrowserImport(context: context) {
return LongCatCookieImporter.hasSession()
}
#endif
@@ -82,7 +82,7 @@ struct LongCatWebFetchStrategy: ProviderFetchStrategy {
}
#if os(macOS)
- if self.allowsBrowserImport(context) {
+ if Self.allowsBrowserImport(context: context) {
if let session = try? LongCatCookieImporter.importSession(),
let header = session.cookieHeader
{
@@ -94,11 +94,14 @@ struct LongCatWebFetchStrategy: ProviderFetchStrategy {
return nil
}
- /// Browser cookie/keychain import is only used for the Auto source (the
- /// default). Manual must use the pasted header and Off disables web auth, so
- /// neither should silently fall back to a browser session.
- private func allowsBrowserImport(_ context: ProviderFetchContext) -> Bool {
+ /// 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 source == nil || source == .auto
+ return context.runtime == .app &&
+ ProviderInteractionContext.current == .userInitiated &&
+ (source == nil || source == .auto)
}
}
diff --git a/Tests/CodexBarTests/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift
index e7ba648d35..946d01b338 100644
--- a/Tests/CodexBarTests/LongCatProviderTests.swift
+++ b/Tests/CodexBarTests/LongCatProviderTests.swift
@@ -134,11 +134,12 @@ struct LongCatProviderTests {
private func context(
env: [String: String],
- cookieSource: ProviderCookieSource) -> ProviderFetchContext
+ cookieSource: ProviderCookieSource,
+ runtime: ProviderRuntime = .app) -> ProviderFetchContext
{
let browserDetection = BrowserDetection(cacheTTL: 0)
return ProviderFetchContext(
- runtime: .app,
+ runtime: runtime,
sourceMode: .web,
includeCredits: false,
webTimeout: 1,
@@ -163,4 +164,22 @@ struct LongCatProviderTests {
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)
+ }
+ }
}
From 5197371d694e8283de0ee0120ab80c9dfceb19ac Mon Sep 17 00:00:00 2001
From: LeoLin
Date: Wed, 24 Jun 2026 10:25:04 +0800
Subject: [PATCH 6/6] fix(longcat): tighten quota and cookie defaults
---
.../Providers/LongCat/LongCatProviderDescriptor.swift | 2 +-
.../Providers/LongCat/LongCatUsageSnapshot.swift | 6 +-----
Sources/CodexBarCore/Providers/Providers.swift | 9 +++++++++
Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift | 6 ++++++
Tests/CodexBarTests/LongCatProviderTests.swift | 8 ++++++++
5 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
index db9b184ec2..6f3068b536 100644
--- a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift
@@ -20,7 +20,7 @@ public enum LongCatProviderDescriptor {
defaultEnabled: false,
isPrimaryProvider: false,
usesAccountFallback: false,
- browserCookieOrder: nil,
+ browserCookieOrder: ProviderBrowserCookieDefaults.longcatCookieImportOrder,
dashboardURL: "https://longcat.chat/platform/",
statusPageURL: nil),
branding: ProviderBranding(
diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
index 0e8285ea09..e2452ed126 100644
--- a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift
@@ -42,11 +42,7 @@ extension LongCatUsageSnapshot {
public func toUsageSnapshot() -> UsageSnapshot {
// Primary: overall token quota consumption (总额度).
- var primary = RateWindow(
- usedPercent: 0,
- windowMinutes: nil,
- resetsAt: nil,
- resetDescription: "No LongCat quota data")
+ var primary: RateWindow?
if let total = totalQuota, total > 0 {
let used = self.resolvedUsed(total: total)
primary = RateWindow(
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 7696193088..2d8829d7ec 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -262,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/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift
index c8cf68d48a..d439404657 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
index 946d01b338..0f0780e75e 100644
--- a/Tests/CodexBarTests/LongCatProviderTests.swift
+++ b/Tests/CodexBarTests/LongCatProviderTests.swift
@@ -66,6 +66,13 @@ struct LongCatProviderTests {
#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)
@@ -113,6 +120,7 @@ struct LongCatProviderTests {
#expect(snapshot.fuelPackTotal == 1000)
#expect(snapshot.fuelPackRemaining == 750)
#expect(snapshot.nearestFuelExpiry != nil)
+ #expect(snapshot.toUsageSnapshot().primary == nil)
}
// MARK: - Envelope