Skip to content

Commit 8a6c0b4

Browse files
committed
feat: add LongCat usage provider
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.
1 parent 9e7a70a commit 8a6c0b4

25 files changed

Lines changed: 1055 additions & 4 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Added
6+
- LongCat: show console quota (总额度), fuel-pack balance (加油包), and today's token usage from a browser session. Thanks @LeoLin990405!
7+
38
## 0.37.1 — 2026-06-21
49

510
### Fixed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import AppKit
2+
import CodexBarCore
3+
import Foundation
4+
import SwiftUI
5+
6+
struct LongCatProviderImplementation: ProviderImplementation {
7+
let id: UsageProvider = .longcat
8+
9+
@MainActor
10+
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
11+
ProviderPresentation { context in
12+
context.store.sourceLabel(for: context.provider)
13+
}
14+
}
15+
16+
@MainActor
17+
func observeSettings(_ settings: SettingsStore) {
18+
_ = settings.longcatUsageDataSource
19+
_ = settings.longcatCookieSource
20+
_ = settings.longcatManualCookieHeader
21+
}
22+
23+
@MainActor
24+
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
25+
.longcat(context.settings.longcatSettingsSnapshot(tokenOverride: context.tokenOverride))
26+
}
27+
28+
@MainActor
29+
func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? {
30+
context.settings.longcatUsageDataSource.rawValue
31+
}
32+
33+
@MainActor
34+
func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode {
35+
switch context.settings.longcatUsageDataSource {
36+
case .web: .web
37+
case .auto, .api, .cli, .oauth: .auto
38+
}
39+
}
40+
41+
@MainActor
42+
func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
43+
let cookieBinding = Binding(
44+
get: { context.settings.longcatCookieSource.rawValue },
45+
set: { raw in
46+
context.settings.longcatCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
47+
})
48+
let options = ProviderCookieSourceUI.options(
49+
allowsOff: true,
50+
keychainDisabled: context.settings.debugDisableKeychainAccess)
51+
52+
let subtitle: () -> String? = {
53+
ProviderCookieSourceUI.subtitle(
54+
source: context.settings.longcatCookieSource,
55+
keychainDisabled: context.settings.debugDisableKeychainAccess,
56+
auto: "Automatic imports longcat.chat cookies from your browser.",
57+
manual: "Paste a Cookie header copied from longcat.chat.",
58+
off: "LongCat cookies are disabled.")
59+
}
60+
61+
return [
62+
ProviderSettingsPickerDescriptor(
63+
id: "longcat-cookie-source",
64+
title: "Cookie source",
65+
subtitle: "Automatic imports longcat.chat cookies from your browser.",
66+
dynamicSubtitle: subtitle,
67+
binding: cookieBinding,
68+
options: options,
69+
isVisible: nil,
70+
onChange: nil),
71+
]
72+
}
73+
74+
@MainActor
75+
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
76+
[
77+
ProviderSettingsFieldDescriptor(
78+
id: "longcat-cookie",
79+
title: "",
80+
subtitle: "",
81+
kind: .secure,
82+
placeholder: "Cookie: \u{2026}",
83+
binding: context.stringBinding(\.longcatManualCookieHeader),
84+
actions: [
85+
ProviderSettingsActionDescriptor(
86+
id: "longcat-open-console",
87+
title: "Open Console",
88+
style: .link,
89+
isVisible: nil,
90+
perform: {
91+
if let url = URL(string: "https://longcat.chat/platform/") {
92+
NSWorkspace.shared.open(url)
93+
}
94+
}),
95+
],
96+
isVisible: { context.settings.longcatCookieSource == .manual },
97+
onActivate: { context.settings.ensureLongCatCookieLoaded() }),
98+
]
99+
}
100+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import CodexBarCore
2+
import Foundation
3+
4+
extension SettingsStore {
5+
var longcatUsageDataSource: ProviderSourceMode {
6+
get { self.configSnapshot.providerConfig(for: .longcat)?.source ?? .auto }
7+
set {
8+
let source: ProviderSourceMode? = switch newValue {
9+
case .auto: .auto
10+
case .web: .web
11+
case .api, .cli, .oauth: .auto
12+
}
13+
self.updateProviderConfig(provider: .longcat) { entry in
14+
entry.source = source
15+
}
16+
self.logProviderModeChange(provider: .longcat, field: "usageSource", value: newValue.rawValue)
17+
}
18+
}
19+
20+
var longcatManualCookieHeader: String {
21+
get { self.configSnapshot.providerConfig(for: .longcat)?.sanitizedCookieHeader ?? "" }
22+
set {
23+
self.updateProviderConfig(provider: .longcat) { entry in
24+
entry.cookieHeader = self.normalizedConfigValue(newValue)
25+
}
26+
self.logSecretUpdate(provider: .longcat, field: "cookieHeader", value: newValue)
27+
}
28+
}
29+
30+
var longcatCookieSource: ProviderCookieSource {
31+
get { self.resolvedCookieSource(provider: .longcat, fallback: .auto) }
32+
set {
33+
self.updateProviderConfig(provider: .longcat) { entry in
34+
entry.cookieSource = newValue
35+
}
36+
self.logProviderModeChange(provider: .longcat, field: "cookieSource", value: newValue.rawValue)
37+
}
38+
}
39+
40+
func ensureLongCatCookieLoaded() {}
41+
}
42+
43+
extension SettingsStore {
44+
func longcatSettingsSnapshot(tokenOverride: TokenAccountOverride?)
45+
-> ProviderSettingsSnapshot.LongCatProviderSettings
46+
{
47+
self.ensureLongCatCookieLoaded()
48+
return self.resolvedCookieSettings(
49+
provider: .longcat,
50+
configuredSource: self.longcatCookieSource,
51+
configuredHeader: self.longcatManualCookieHeader,
52+
tokenOverride: tokenOverride)
53+
}
54+
}

Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ enum ProviderImplementationRegistry {
6666
case .deepgram: DeepgramProviderImplementation()
6767
case .poe: PoeProviderImplementation()
6868
case .chutes: ChutesProviderImplementation()
69+
case .longcat: LongCatProviderImplementation()
6970
}
7071
}
7172

Lines changed: 4 additions & 0 deletions
Loading

Sources/CodexBar/UsageStore.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,7 @@ extension UsageStore {
10861086
case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .devin,
10871087
.vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao,
10881088
.abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock,
1089-
.grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes:
1089+
.grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, .longcat:
10901090
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
10911091
}
10921092
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.
22

33
enum CodexParserHash {
4-
static let value = "800a06dead603ea7"
4+
static let value = "910b475f0fded3e5"
55
}

Sources/CodexBarCore/Logging/LogCategories.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ public enum LogCategories {
4242
public static let kimiTokenStore = "kimi-token-store"
4343
public static let kimiWeb = "kimi-web"
4444
public static let kiro = "kiro"
45+
public static let longcatAPI = "longcat-api"
46+
public static let longcatCookie = "longcat-cookie"
47+
public static let longcatWeb = "longcat-web"
4548
public static let launchAtLogin = "launch-at-login"
4649
public static let login = "login"
4750
public static let logging = "logging"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Foundation
2+
3+
public enum LongCatAPIError: LocalizedError, Sendable, Equatable {
4+
case missingCookies
5+
case invalidSession
6+
case invalidRequest(String)
7+
case networkError(String)
8+
case apiError(String)
9+
case parseFailed(String)
10+
11+
public var errorDescription: String? {
12+
switch self {
13+
case .missingCookies:
14+
"LongCat session cookies are missing. Sign in at longcat.chat, or paste a cookie header."
15+
case .invalidSession:
16+
"LongCat session is invalid or expired. Please sign in again at longcat.chat."
17+
case let .invalidRequest(message):
18+
"Invalid request: \(message)"
19+
case let .networkError(message):
20+
"LongCat network error: \(message)"
21+
case let .apiError(message):
22+
"LongCat API error: \(message)"
23+
case let .parseFailed(message):
24+
"Failed to parse LongCat usage data: \(message)"
25+
}
26+
}
27+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Foundation
2+
3+
public struct LongCatCookieOverride: Sendable {
4+
/// Full `Cookie:` header value (e.g. `name=value; name2=value2`).
5+
public let cookieHeader: String
6+
7+
public init(cookieHeader: String) {
8+
self.cookieHeader = cookieHeader
9+
}
10+
}
11+
12+
public enum LongCatCookieHeader {
13+
private static let log = CodexBarLog.logger(LogCategories.longcatCookie)
14+
private static let headerPatterns: [String] = [
15+
#"(?i)-H\s*'Cookie:\s*([^']+)'"#,
16+
#"(?i)-H\s*"Cookie:\s*([^"]+)""#,
17+
#"(?i)\bcookie:\s*'([^']+)'"#,
18+
#"(?i)\bcookie:\s*"([^"]+)""#,
19+
#"(?i)\bcookie:\s*([^\r\n]+)"#,
20+
]
21+
22+
public static func resolveCookieOverride(context: ProviderFetchContext) -> LongCatCookieOverride? {
23+
if let settings = context.settings?.longcat, settings.cookieSource == .manual {
24+
if let manual = settings.manualCookieHeader, !manual.isEmpty {
25+
return self.override(from: manual)
26+
}
27+
}
28+
29+
if let envHeader = self.override(from: context.env["LONGCAT_MANUAL_COOKIE"]) {
30+
return envHeader
31+
}
32+
33+
return nil
34+
}
35+
36+
public static func override(from raw: String?) -> LongCatCookieOverride? {
37+
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
38+
return nil
39+
}
40+
41+
if let header = self.extractHeader(from: raw) {
42+
return LongCatCookieOverride(cookieHeader: header)
43+
}
44+
45+
// A bare `name=value; ...` string is itself a usable cookie header.
46+
if raw.contains("=") {
47+
return LongCatCookieOverride(cookieHeader: raw)
48+
}
49+
50+
return nil
51+
}
52+
53+
private static func extractHeader(from raw: String) -> String? {
54+
for pattern in self.headerPatterns {
55+
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { continue }
56+
let range = NSRange(raw.startIndex..<raw.endIndex, in: raw)
57+
guard let match = regex.firstMatch(in: raw, options: [], range: range),
58+
match.numberOfRanges >= 2,
59+
let captureRange = Range(match.range(at: 1), in: raw)
60+
else {
61+
continue
62+
}
63+
let captured = String(raw[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines)
64+
if !captured.isEmpty { return captured }
65+
}
66+
return nil
67+
}
68+
}

0 commit comments

Comments
 (0)