diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2363fcb0a3..150d0703df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
- Ollama: add API key authentication as an alternative to browser cookies for validating Cloud access (#1044). Thanks @nandorocker!
- Azure OpenAI: add deployment-status validation via API key, endpoint, and deployment settings (#1045). Thanks @ZenoRewn!
- Localizations: add Spanish and Catalan language packs and fill missing localization keys (#1041). Thanks @seifreed!
+- Providers: T3 Chat - add web-session usage tracking, can paste a full browser cURL when cookie-only refreshes hit a 429 challenge (#1091). Thanks @Quicksaver!
### Fixed
- Menu: restore full-width provider switcher quota bars and refresh them while the menu stays open (#1094). Thanks @bcharleson!
diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift
index 08e9d2de0d..e70e330863 100644
--- a/Sources/CodexBar/PreferencesDebugPane.swift
+++ b/Sources/CodexBar/PreferencesDebugPane.swift
@@ -315,6 +315,7 @@ struct DebugPane: View {
Text("Antigravity").tag(UsageProvider.antigravity)
Text("Augment").tag(UsageProvider.augment)
Text("Amp").tag(UsageProvider.amp)
+ Text("T3 Chat").tag(UsageProvider.t3chat)
Text("Ollama").tag(UsageProvider.ollama)
}
.pickerStyle(.segmented)
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 54fc82c6ee..8c08638471 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -37,6 +37,7 @@ enum ProviderImplementationRegistry {
case .kimik2: KimiK2ProviderImplementation()
case .moonshot: MoonshotProviderImplementation()
case .amp: AmpProviderImplementation()
+ case .t3chat: T3ChatProviderImplementation()
case .ollama: OllamaProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
diff --git a/Sources/CodexBar/Providers/T3Chat/T3ChatProviderImplementation.swift b/Sources/CodexBar/Providers/T3Chat/T3ChatProviderImplementation.swift
new file mode 100644
index 0000000000..6a1c364636
--- /dev/null
+++ b/Sources/CodexBar/Providers/T3Chat/T3ChatProviderImplementation.swift
@@ -0,0 +1,81 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct T3ChatProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .t3chat
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.t3ChatCookieSource
+ _ = settings.t3ChatCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .t3chat(context.settings.t3ChatSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.t3ChatCookieSource.rawValue },
+ set: { raw in
+ context.settings.t3ChatCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let cookieOptions = ProviderCookieSourceUI.options(
+ allowsOff: false,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+
+ let cookieSubtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.t3ChatCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Automatically imports browser cookies.",
+ manual: "Paste a Cookie header or cURL capture from T3 Chat settings.",
+ off: "Paste a Cookie header or cURL capture from T3 Chat settings.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "t3chat-cookie-source",
+ title: "Cookie source",
+ subtitle: "Automatically imports browser cookies.",
+ dynamicSubtitle: cookieSubtitle,
+ binding: cookieBinding,
+ options: cookieOptions,
+ isVisible: nil,
+ onChange: nil),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "t3chat-cookie",
+ title: "T3 Chat cookie",
+ subtitle: "Paste a Cookie header or full cURL capture from T3 Chat settings.",
+ kind: .secure,
+ placeholder: "Cookie: ...",
+ binding: context.stringBinding(\.t3ChatCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "t3chat-open-settings",
+ title: "Open T3 Chat Settings",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://t3.chat/settings/customization") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.t3ChatCookieSource == .manual },
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/T3Chat/T3ChatSettingsStore.swift b/Sources/CodexBar/Providers/T3Chat/T3ChatSettingsStore.swift
new file mode 100644
index 0000000000..8f092953c9
--- /dev/null
+++ b/Sources/CodexBar/Providers/T3Chat/T3ChatSettingsStore.swift
@@ -0,0 +1,62 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var t3ChatCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .t3chat)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .t3chat) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .t3chat, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var t3ChatCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .t3chat, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .t3chat) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .t3chat, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+}
+
+extension SettingsStore {
+ func t3ChatSettingsSnapshot(
+ tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.T3ChatProviderSettings
+ {
+ ProviderSettingsSnapshot.T3ChatProviderSettings(
+ cookieSource: self.t3ChatSnapshotCookieSource(tokenOverride: tokenOverride),
+ manualCookieHeader: self.t3ChatSnapshotCookieHeader(tokenOverride: tokenOverride))
+ }
+
+ private func t3ChatSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String {
+ let fallback = self.t3ChatCookieHeader
+ guard let support = TokenAccountSupportCatalog.support(for: .t3chat),
+ case .cookieHeader = support.injection
+ else {
+ return fallback
+ }
+ guard let account = ProviderTokenAccountSelection.selectedAccount(
+ provider: .t3chat,
+ settings: self,
+ override: tokenOverride)
+ else {
+ return fallback
+ }
+ return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support)
+ }
+
+ private func t3ChatSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource {
+ let fallback = self.t3ChatCookieSource
+ guard let support = TokenAccountSupportCatalog.support(for: .t3chat),
+ support.requiresManualCookieSource
+ else {
+ return fallback
+ }
+ if self.tokenAccounts(for: .t3chat).isEmpty { return fallback }
+ return .manual
+ }
+}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-t3chat.svg b/Sources/CodexBar/Resources/ProviderIcon-t3chat.svg
new file mode 100644
index 0000000000..68a174a697
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-t3chat.svg
@@ -0,0 +1,5 @@
+
diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift
index 9ad8fd0bee..2af6b263b1 100644
--- a/Sources/CodexBar/SettingsStore+MenuObservation.swift
+++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift
@@ -57,6 +57,7 @@ extension SettingsStore {
_ = self.kimiCookieSource
_ = self.augmentCookieSource
_ = self.ampCookieSource
+ _ = self.t3ChatCookieSource
_ = self.ollamaCookieSource
_ = self.mergeIcons
_ = self.switcherShowsIcons
@@ -79,6 +80,7 @@ extension SettingsStore {
_ = self.kiloAPIToken
_ = self.augmentCookieHeader
_ = self.ampCookieHeader
+ _ = self.t3ChatCookieHeader
_ = self.ollamaCookieHeader
_ = self.copilotAPIToken
_ = self.warpAPIToken
diff --git a/Sources/CodexBar/UsageStore+Logging.swift b/Sources/CodexBar/UsageStore+Logging.swift
index d5c9830d06..7b4ff0826b 100644
--- a/Sources/CodexBar/UsageStore+Logging.swift
+++ b/Sources/CodexBar/UsageStore+Logging.swift
@@ -16,6 +16,7 @@ extension UsageStore {
"kimiCookieSource": self.settings.kimiCookieSource.rawValue,
"augmentCookieSource": self.settings.augmentCookieSource.rawValue,
"ampCookieSource": self.settings.ampCookieSource.rawValue,
+ "t3ChatCookieSource": self.settings.t3ChatCookieSource.rawValue,
"ollamaCookieSource": self.settings.ollamaCookieSource.rawValue,
"openAIWebAccess": self.settings.openAIWebAccessEnabled ? "1" : "0",
"openAIWebBatterySaver": self.settings.openAIWebBatterySaverEnabled ? "1" : "0",
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 5c7cee212e..12050300c6 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -994,6 +994,7 @@ extension UsageStore {
.bedrock: "Bedrock debug log not yet implemented",
.grok: "Grok debug log not yet implemented",
.groq: "Groq debug log not yet implemented",
+ .t3chat: "T3 Chat debug log not yet implemented",
.llmproxy: "LLM Proxy debug log not yet implemented",
.deepgram: "Deepgram debug log not yet implemented",
]
@@ -1072,7 +1073,8 @@ extension UsageStore {
hasTokenAccount: deepSeekHasTokenAccount)
case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
.kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .abacus, .mistral, .codebuff, .crof,
- .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, .grok, .groq, .llmproxy, .deepgram:
+ .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy,
+ .deepgram:
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 522698f058..07577150c4 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 = "e478cccb0110e8ad"
+ static let value = "881a341768657996"
}
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index fad72ea51e..2119ec2612 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -72,6 +72,7 @@ public enum LogCategories {
public static let subprocess = "subprocess"
public static let syntheticTokenStore = "synthetic-token-store"
public static let syntheticUsage = "synthetic-usage"
+ public static let t3chat = "t3chat"
public static let terminal = "terminal"
public static let tokenAccounts = "token-accounts"
public static let tokenCost = "token-cost"
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index fd63014985..9ce19e30f1 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -77,6 +77,7 @@ public enum ProviderDescriptorRegistry {
.kimik2: KimiK2ProviderDescriptor.descriptor,
.moonshot: MoonshotProviderDescriptor.descriptor,
.amp: AmpProviderDescriptor.descriptor,
+ .t3chat: T3ChatProviderDescriptor.descriptor,
.ollama: OllamaProviderDescriptor.descriptor,
.synthetic: SyntheticProviderDescriptor.descriptor,
.openrouter: OpenRouterProviderDescriptor.descriptor,
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index c210fce09d..6e8e3c0b6c 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -20,6 +20,7 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: AugmentProviderSettings? = nil,
moonshot: MoonshotProviderSettings? = nil,
amp: AmpProviderSettings? = nil,
+ t3chat: T3ChatProviderSettings? = nil,
ollama: OllamaProviderSettings? = nil,
jetbrains: JetBrainsProviderSettings? = nil,
windsurf: WindsurfProviderSettings? = nil,
@@ -48,6 +49,7 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: augment,
moonshot: moonshot,
amp: amp,
+ t3chat: t3chat,
ollama: ollama,
jetbrains: jetbrains,
windsurf: windsurf,
@@ -253,6 +255,16 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct T3ChatProviderSettings: Sendable {
+ public let cookieSource: ProviderCookieSource
+ public let manualCookieHeader: String?
+
+ public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) {
+ self.cookieSource = cookieSource
+ self.manualCookieHeader = manualCookieHeader
+ }
+ }
+
public struct CommandCodeProviderSettings: Sendable {
public let cookieSource: ProviderCookieSource
public let manualCookieHeader: String?
@@ -366,6 +378,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let augment: AugmentProviderSettings?
public let moonshot: MoonshotProviderSettings?
public let amp: AmpProviderSettings?
+ public let t3chat: T3ChatProviderSettings?
public let commandcode: CommandCodeProviderSettings?
public let ollama: OllamaProviderSettings?
public let jetbrains: JetBrainsProviderSettings?
@@ -399,6 +412,7 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: AugmentProviderSettings?,
moonshot: MoonshotProviderSettings? = nil,
amp: AmpProviderSettings?,
+ t3chat: T3ChatProviderSettings? = nil,
commandcode: CommandCodeProviderSettings? = nil,
ollama: OllamaProviderSettings?,
jetbrains: JetBrainsProviderSettings? = nil,
@@ -427,6 +441,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.augment = augment
self.moonshot = moonshot
self.amp = amp
+ self.t3chat = t3chat
self.commandcode = commandcode
self.ollama = ollama
self.jetbrains = jetbrains
@@ -456,6 +471,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case augment(ProviderSettingsSnapshot.AugmentProviderSettings)
case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings)
case amp(ProviderSettingsSnapshot.AmpProviderSettings)
+ case t3chat(ProviderSettingsSnapshot.T3ChatProviderSettings)
case commandcode(ProviderSettingsSnapshot.CommandCodeProviderSettings)
case ollama(ProviderSettingsSnapshot.OllamaProviderSettings)
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
@@ -486,6 +502,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var augment: ProviderSettingsSnapshot.AugmentProviderSettings?
public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings?
public var amp: ProviderSettingsSnapshot.AmpProviderSettings?
+ public var t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings?
public var commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings?
public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings?
public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings?
@@ -520,6 +537,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .augment(value): self.augment = value
case let .moonshot(value): self.moonshot = value
case let .amp(value): self.amp = value
+ case let .t3chat(value): self.t3chat = value
case let .commandcode(value): self.commandcode = value
case let .ollama(value): self.ollama = value
case let .jetbrains(value): self.jetbrains = value
@@ -552,6 +570,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
augment: self.augment,
moonshot: self.moonshot,
amp: self.amp,
+ t3chat: self.t3chat,
commandcode: self.commandcode,
ollama: self.ollama,
jetbrains: self.jetbrains,
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 3bac0341b4..1e46787b30 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -27,6 +27,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case kimik2
case moonshot
case amp
+ case t3chat
case ollama
case synthetic
case warp
@@ -77,6 +78,7 @@ public enum IconStyle: Sendable, CaseIterable {
case jetbrains
case moonshot
case amp
+ case t3chat
case ollama
case synthetic
case warp
diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift
new file mode 100644
index 0000000000..9317b40736
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift
@@ -0,0 +1,85 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum T3ChatProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .t3chat,
+ metadata: ProviderMetadata(
+ id: .t3chat,
+ displayName: "T3 Chat",
+ sessionLabel: "Base",
+ weeklyLabel: "Overage",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show T3 Chat usage",
+ cliName: "t3chat",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder,
+ dashboardURL: "https://t3.chat/settings/customization",
+ subscriptionDashboardURL: "https://t3.chat/settings/subscription",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .t3chat,
+ iconResourceName: "ProviderIcon-t3chat",
+ color: ProviderColor(red: 245 / 255, green: 102 / 255, blue: 71 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "T3 Chat cost summary is not supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [T3ChatWebFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "t3chat",
+ aliases: ["t3-chat", "t3"],
+ versionDetector: nil))
+ }
+}
+
+struct T3ChatWebFetchStrategy: ProviderFetchStrategy {
+ let id: String = "t3chat.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ let cookieSource = context.settings?.t3chat?.cookieSource ?? .auto
+ guard cookieSource != .off else { return false }
+ if cookieSource == .manual {
+ return T3ChatUsageFetcher.requestContext(from: context.settings?.t3chat?.manualCookieHeader) != nil
+ }
+ #if os(macOS)
+ return true
+ #else
+ return false
+ #endif
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let fetcher = T3ChatUsageFetcher(browserDetection: context.browserDetection)
+ let manual = Self.manualCookieHeader(from: context)
+ let logger: ((String) -> Void)? = context.verbose
+ ? { msg in CodexBarLog.logger(LogCategories.t3chat).verbose(msg) }
+ : nil
+ let snapshot = try await fetcher.fetch(
+ cookieHeaderOverride: manual,
+ timeout: context.webTimeout,
+ logger: logger)
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: "web")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func manualCookieHeader(from context: ProviderFetchContext) -> String? {
+ guard context.settings?.t3chat?.cookieSource == .manual else { return nil }
+ return context.settings?.t3chat?.manualCookieHeader
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift
new file mode 100644
index 0000000000..bddc960e47
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift
@@ -0,0 +1,366 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+#if os(macOS)
+import SweetCookieKit
+#endif
+
+#if os(macOS)
+private let t3ChatCookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.t3chat]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+public enum T3ChatCookieImporter {
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["t3.chat", "www.t3.chat"]
+
+ public struct SessionInfo: Sendable {
+ public let cookieHeader: String
+ public let sourceLabel: String
+
+ public init(cookieHeader: String, sourceLabel: String) {
+ self.cookieHeader = cookieHeader
+ self.sourceLabel = sourceLabel
+ }
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection,
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let log: (String) -> Void = { msg in logger?("[t3chat-cookie] \(msg)") }
+ let installed = t3ChatCookieImportOrder.cookieImportCandidates(using: browserDetection)
+
+ for browserSource in installed {
+ do {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let sources = try self.cookieClient.codexBarRecords(
+ matching: query,
+ in: browserSource,
+ logger: log)
+ for source in sources where !source.records.isEmpty {
+ let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
+ guard !cookies.isEmpty else { continue }
+ let names = cookies.map(\.name).joined(separator: ", ")
+ log("\(source.label) cookies: \(names)")
+ let header = cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
+ return SessionInfo(cookieHeader: header, sourceLabel: source.label)
+ }
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
+ }
+ }
+
+ throw T3ChatUsageError.noSessionCookie
+ }
+}
+#endif
+
+public struct T3ChatUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.t3chat)
+ private static let baseURL = URL(string: "https://t3.chat")!
+ private static let refererURL = URL(string: "https://t3.chat/settings/customization")!
+ /// Browser fingerprint defaults are only fallbacks; full cURL captures override these forwarded headers.
+ private static 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"
+ /// Captured from T3 Chat's getCustomerData tRPC request shape in May 2026.
+ private static let input = #"{"0":{"json":{"sessionId":null},"meta":{"values":{"sessionId":["undefined"]}}}}"#
+ private static let forwardedManualHeaders = [
+ "accept": "Accept",
+ "accept-language": "Accept-Language",
+ "cache-control": "Cache-Control",
+ "pragma": "Pragma",
+ "priority": "Priority",
+ "referer": "Referer",
+ "sec-fetch-dest": "Sec-Fetch-Dest",
+ "sec-fetch-mode": "Sec-Fetch-Mode",
+ "sec-fetch-site": "Sec-Fetch-Site",
+ "trpc-accept": "trpc-accept",
+ "user-agent": "User-Agent",
+ "x-client-context": "x-client-context",
+ "x-deployment-id": "X-Deployment-Id",
+ "x-trpc-batch": "x-trpc-batch",
+ "x-trpc-source": "x-trpc-source",
+ ]
+
+ public struct RequestContext: Sendable {
+ public let cookieHeader: String
+ public let headers: [String: String]
+
+ public init(cookieHeader: String, headers: [String: String] = [:]) {
+ self.cookieHeader = cookieHeader
+ self.headers = headers
+ }
+ }
+
+ public let browserDetection: BrowserDetection
+
+ public init(browserDetection: BrowserDetection) {
+ self.browserDetection = browserDetection
+ }
+
+ public func fetch(
+ cookieHeaderOverride: String? = nil,
+ timeout: TimeInterval = 15,
+ logger: ((String) -> Void)? = nil,
+ now: Date = Date(),
+ transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot
+ {
+ let log: (String) -> Void = { msg in logger?("[t3chat] \(msg)") }
+ let context = try await self.resolveRequestContext(override: cookieHeaderOverride, logger: log)
+ if let logger {
+ let names = CookieHeaderNormalizer.pairs(from: context.cookieHeader).map(\.name)
+ if !names.isEmpty {
+ logger("[t3chat] Cookie names: \(names.joined(separator: ", "))")
+ }
+ if !context.headers.isEmpty {
+ let headerNames = context.headers.keys.sorted().joined(separator: ", ")
+ logger("[t3chat] Forwarding captured headers: \(headerNames)")
+ }
+ }
+ return try await Self.fetchCustomerData(
+ context: context,
+ timeout: timeout,
+ now: now,
+ transport: transport)
+ }
+
+ public func debugRawProbe(cookieHeaderOverride: String? = nil) async -> String {
+ let stamp = ISO8601DateFormatter().string(from: Date())
+ var lines: [String] = []
+ lines.append("=== T3 Chat Debug Probe @ \(stamp) ===")
+ lines.append("")
+
+ do {
+ let snapshot = try await self.fetch(
+ cookieHeaderOverride: cookieHeaderOverride,
+ logger: { msg in lines.append(msg) })
+ lines.append("")
+ lines.append("Fetch Success")
+ lines.append("subTier=\(snapshot.customerData.subTier ?? "nil")")
+ lines.append("usageBand=\(snapshot.customerData.usageBand ?? "nil")")
+ lines
+ .append(
+ "usageFourHourPercentage=\(snapshot.customerData.usageFourHourPercentage?.description ?? "nil")")
+ lines.append("usageMonthPercentage=\(snapshot.customerData.usageMonthPercentage?.description ?? "nil")")
+ lines.append("usagePeriodPercentage=\(snapshot.customerData.usagePeriodPercentage?.description ?? "nil")")
+ lines
+ .append(
+ "usageFourHourNextResetAt=\(snapshot.customerData.usageFourHourNextResetAt?.description ?? "nil")")
+ lines.append("billingNextResetAt=\(snapshot.customerData.billingNextResetAt?.description ?? "nil")")
+ } catch {
+ lines.append("")
+ lines.append("Probe Failed: \(error.localizedDescription)")
+ }
+
+ return lines.joined(separator: "\n")
+ }
+
+ public static func fetchCustomerData(
+ cookieHeader: String,
+ timeout: TimeInterval = 15,
+ now: Date = Date(),
+ transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot
+ {
+ guard let normalizedCookieHeader = CookieHeaderNormalizer.normalize(cookieHeader) else {
+ throw T3ChatUsageError.noSessionCookie
+ }
+ return try await self.fetchCustomerData(
+ context: RequestContext(cookieHeader: normalizedCookieHeader),
+ timeout: timeout,
+ now: now,
+ transport: transport)
+ }
+
+ public static func fetchCustomerData(
+ context: RequestContext,
+ timeout: TimeInterval = 15,
+ now: Date = Date(),
+ transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot
+ {
+ guard let normalizedCookieHeader = CookieHeaderNormalizer.normalize(context.cookieHeader) else {
+ throw T3ChatUsageError.noSessionCookie
+ }
+
+ let url = try self.customerDataURL()
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.timeoutInterval = timeout
+ self.applyDefaultHeaders(to: &request)
+ for (name, value) in context.headers {
+ request.setValue(value, forHTTPHeaderField: name)
+ }
+ request.setValue(self.baseURL.absoluteString, forHTTPHeaderField: "Origin")
+ request.setValue(normalizedCookieHeader, forHTTPHeaderField: "Cookie")
+
+ let response = try await transport.response(for: request)
+ let data = response.data
+ guard response.statusCode == 200 else {
+ let body = String(data: data.prefix(200), encoding: .utf8) ?? ""
+ Self.log.error("T3 Chat API returned \(response.statusCode): \(body)")
+ if response.statusCode == 401 || response.statusCode == 403 {
+ throw T3ChatUsageError.invalidCredentials
+ }
+ if response.statusCode == 429,
+ response.response.value(forHTTPHeaderField: "x-vercel-mitigated") == "challenge"
+ {
+ throw T3ChatUsageError.vercelChallenge
+ }
+ throw T3ChatUsageError.apiError("HTTP \(response.statusCode)")
+ }
+
+ do {
+ return try T3ChatUsageParser.parseJSONLines(data, now: now)
+ } catch {
+ let preview = String(data: data.prefix(500), encoding: .utf8) ?? ""
+ Self.log.error("T3 Chat parse failed: \(error.localizedDescription) response=\(preview)")
+ throw error
+ }
+ }
+
+ private func resolveRequestContext(
+ override: String?,
+ logger: ((String) -> Void)?) async throws -> RequestContext
+ {
+ if let override = Self.requestContext(from: override) {
+ let source = override.headers.isEmpty ? "manual cookie header" : "manual cURL capture"
+ logger?("[t3chat] Using \(source)")
+ return override
+ }
+
+ #if os(macOS)
+ let session = try T3ChatCookieImporter.importSession(
+ browserDetection: self.browserDetection,
+ logger: logger)
+ logger?("[t3chat] Using cookies from \(session.sourceLabel)")
+ return RequestContext(cookieHeader: session.cookieHeader)
+ #else
+ throw T3ChatUsageError.noSessionCookie
+ #endif
+ }
+
+ static func requestContext(from raw: String?) -> RequestContext? {
+ guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
+ let headerFields = Self.headerFields(from: raw)
+ guard let cookieHeader = Self.cookieHeader(from: headerFields) ?? CookieHeaderNormalizer.normalize(raw) else {
+ return nil
+ }
+ let headers = Self.forwardedHeaders(from: headerFields)
+ return RequestContext(cookieHeader: cookieHeader, headers: headers)
+ }
+
+ private static func applyDefaultHeaders(to request: inout URLRequest) {
+ request.setValue("*/*", forHTTPHeaderField: "Accept")
+ request.setValue("application/jsonl", forHTTPHeaderField: "trpc-accept")
+ request.setValue("web-client", forHTTPHeaderField: "x-trpc-source")
+ request.setValue("true", forHTTPHeaderField: "x-trpc-batch")
+ request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
+ request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
+ request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer")
+ request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
+ request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
+ request.setValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
+ request.setValue("u=4", forHTTPHeaderField: "Priority")
+ request.setValue("no-cache", forHTTPHeaderField: "Pragma")
+ request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
+ }
+
+ private static func forwardedHeaders(from fields: [String]) -> [String: String] {
+ var headers: [String: String] = [:]
+ for field in fields {
+ guard let colon = field.firstIndex(of: ":") else { continue }
+ let rawName = field[.. String? {
+ for field in fields {
+ guard let colon = field.firstIndex(of: ":") else { continue }
+ let rawName = field[.. [String] {
+ var fields: [String] = []
+ let pattern =
+ #"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# +
+ #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"#
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields }
+ let range = NSRange(raw.startIndex.. String? {
+ guard match.numberOfRanges > index,
+ let range = Range(match.range(at: index), in: raw)
+ else {
+ return nil
+ }
+ return String(raw[range])
+ }
+
+ private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String {
+ var output = ""
+ var index = raw.startIndex
+ while index < raw.endIndex {
+ guard raw[index] == "\\" else {
+ output.append(raw[index])
+ index = raw.index(after: index)
+ continue
+ }
+ let next = raw.index(after: index)
+ guard next < raw.endIndex else { return output }
+ switch raw[next] {
+ case "n" where ansi:
+ output.append("\n")
+ case "r" where ansi:
+ output.append("\r")
+ case "t" where ansi:
+ output.append("\t")
+ case "\n":
+ break
+ default:
+ output.append(raw[next])
+ }
+ index = raw.index(after: next)
+ }
+ return output
+ }
+
+ private static func customerDataURL() throws -> URL {
+ var components = URLComponents(string: "https://t3.chat/api/trpc/getCustomerData")!
+ components.queryItems = [
+ URLQueryItem(name: "batch", value: "1"),
+ URLQueryItem(name: "input", value: self.input),
+ ]
+ guard let url = components.url else {
+ throw T3ChatUsageError.apiError("Failed to build customer data URL.")
+ }
+ return url
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageSnapshot.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageSnapshot.swift
new file mode 100644
index 0000000000..e75c05e7ed
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageSnapshot.swift
@@ -0,0 +1,180 @@
+import Foundation
+
+public enum T3ChatUsageError: LocalizedError, Sendable {
+ case noSessionCookie
+ case invalidCredentials
+ case vercelChallenge
+ case apiError(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .noSessionCookie:
+ "No T3 Chat cookies found. Please log in to t3.chat in your browser."
+ case .invalidCredentials:
+ "T3 Chat session cookie is invalid or expired."
+ case .vercelChallenge:
+ "T3 Chat returned a Vercel security challenge. Paste the full browser cURL request, " +
+ "not just the Cookie header."
+ case let .apiError(message):
+ "T3 Chat API error: \(message)"
+ case let .parseFailed(message):
+ "Could not parse T3 Chat usage: \(message)"
+ }
+ }
+}
+
+public struct T3ChatSubscription: Decodable, Sendable {
+ public let productId: String?
+ public let productName: String?
+ public let status: String?
+ public let currentPeriodStart: TimeInterval?
+ public let currentPeriodEnd: TimeInterval?
+ public let canceledAt: TimeInterval?
+ public let trialEndsAt: TimeInterval?
+}
+
+public struct T3ChatCustomerData: Decodable, Sendable {
+ public let subTier: String?
+ public let subscription: T3ChatSubscription?
+ public let lifetimeBalance: Double?
+ public let usageBand: String?
+ public let billingNextResetAt: TimeInterval?
+ public let usageFourHourPercentage: Double?
+ public let usageMonthPercentage: Double?
+ public let usageFourHourNextResetAt: TimeInterval?
+ public let usagePeriodPercentage: Double?
+ public let usageWindowNextResetAt: TimeInterval?
+
+ public var planName: String? {
+ let raw = self.subscription?.productName ?? self.subTier
+ guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
+ return nil
+ }
+ return raw.split(separator: "-").map { part in
+ part.prefix(1).uppercased() + String(part.dropFirst())
+ }.joined(separator: " ")
+ }
+}
+
+public struct T3ChatUsageSnapshot: Sendable {
+ public let customerData: T3ChatCustomerData
+ public let updatedAt: Date
+
+ public init(customerData: T3ChatCustomerData, updatedAt: Date) {
+ self.customerData = customerData
+ self.updatedAt = updatedAt
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let baseReset = Self.date(fromMilliseconds: self.customerData.usageFourHourNextResetAt)
+ ?? Self.date(fromMilliseconds: self.customerData.usageWindowNextResetAt)
+ // billingNextResetAt tracks the usage window reset, not the overage billing period.
+ // If subscription metadata is absent, leave the overage reset unknown instead of showing the base reset.
+ let overageReset = Self.date(fromMilliseconds: self.customerData.subscription?.currentPeriodEnd)
+
+ let primary = RateWindow(
+ usedPercent: Self.percent(self.customerData.usageFourHourPercentage),
+ windowMinutes: 4 * 60,
+ resetsAt: baseReset,
+ resetDescription: Self.description(label: "Base", usageBand: self.customerData.usageBand))
+
+ let secondaryPercent = self.customerData.usageMonthPercentage
+ ?? self.customerData.usagePeriodPercentage
+ let secondary = RateWindow(
+ usedPercent: Self.percent(secondaryPercent),
+ windowMinutes: nil,
+ resetsAt: overageReset,
+ resetDescription: "Overage")
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .t3chat,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: self.customerData.planName)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+
+ private static func percent(_ raw: Double?) -> Double {
+ min(100, max(0, raw ?? 0))
+ }
+
+ private static func date(fromMilliseconds raw: TimeInterval?) -> Date? {
+ guard let raw, raw > 0 else { return nil }
+ // T3 Chat currently returns JavaScript epoch milliseconds, while some subscription fields may be seconds.
+ let seconds = raw > 10_000_000_000 ? raw / 1000 : raw
+ return Date(timeIntervalSince1970: seconds)
+ }
+
+ private static func description(label: String, usageBand: String?) -> String {
+ guard let usageBand = usageBand?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !usageBand.isEmpty
+ else {
+ return label
+ }
+ return "\(label) - \(usageBand)"
+ }
+}
+
+public enum T3ChatUsageParser {
+ public static func parseJSONLines(_ data: Data, now: Date = Date()) throws -> T3ChatUsageSnapshot {
+ guard let text = String(data: data, encoding: .utf8) else {
+ throw T3ChatUsageError.parseFailed("Response is not UTF-8.")
+ }
+ return try self.parseJSONLines(text, now: now)
+ }
+
+ public static func parseJSONLines(_ text: String, now: Date = Date()) throws -> T3ChatUsageSnapshot {
+ let lines = text.split(whereSeparator: \.isNewline)
+ for line in lines {
+ guard let data = String(line).data(using: .utf8) else { continue }
+ guard let object = try? JSONSerialization.jsonObject(with: data) else { continue }
+ guard let customerObject = self.findCustomerData(in: object) else { continue }
+ let customerData = try self.decodeCustomerData(customerObject)
+ return T3ChatUsageSnapshot(customerData: customerData, updatedAt: now)
+ }
+
+ throw T3ChatUsageError.parseFailed("Missing customer data object.")
+ }
+
+ private static func findCustomerData(in object: Any) -> [String: Any]? {
+ if let dictionary = object as? [String: Any] {
+ if dictionary["usageFourHourPercentage"] != nil ||
+ dictionary["usageMonthPercentage"] != nil ||
+ dictionary["subscription"] != nil && dictionary["usageBand"] != nil
+ {
+ return dictionary
+ }
+
+ for value in dictionary.values {
+ if let found = self.findCustomerData(in: value) {
+ return found
+ }
+ }
+ }
+
+ if let array = object as? [Any] {
+ for value in array {
+ if let found = self.findCustomerData(in: value) {
+ return found
+ }
+ }
+ }
+
+ return nil
+ }
+
+ private static func decodeCustomerData(_ object: [String: Any]) throws -> T3ChatCustomerData {
+ do {
+ let data = try JSONSerialization.data(withJSONObject: object, options: [])
+ return try JSONDecoder().decode(T3ChatCustomerData.self, from: data)
+ } catch {
+ throw T3ChatUsageError.parseFailed(error.localizedDescription)
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 22b7bb0469..28364c3079 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -369,9 +369,9 @@ enum CostUsageScanner {
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory,
.copilot, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama,
- .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral, .deepseek,
- .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, .groq, .llmproxy,
- .deepgram:
+ .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral,
+ .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, .groq,
+ .llmproxy, .deepgram:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 60b8d14fe3..033af2f0ad 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -75,6 +75,7 @@ enum ProviderChoice: String, AppEnum {
case .kimik2: return nil // Kimi K2 not yet supported in widgets
case .moonshot: return nil // Moonshot not yet supported in widgets
case .amp: return nil // Amp not yet supported in widgets
+ case .t3chat: return nil // T3 Chat not yet supported in widgets
case .ollama: return nil // Ollama not yet supported in widgets
case .synthetic: return nil // Synthetic not yet supported in widgets
case .openrouter: return nil // OpenRouter not yet supported in widgets
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 2cba949f1d..6e632cae17 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -281,6 +281,7 @@ private struct ProviderSwitchChip: View {
case .kimik2: "Kimi K2"
case .moonshot: "Moonshot"
case .amp: "Amp"
+ case .t3chat: "T3 Chat"
case .ollama: "Ollama"
case .synthetic: "Synthetic"
case .openrouter: "OpenRouter"
@@ -660,6 +661,8 @@ enum WidgetColors {
Color(red: 32 / 255, green: 93 / 255, blue: 235 / 255)
case .amp:
Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red
+ case .t3chat:
+ Color(red: 245 / 255, green: 102 / 255, blue: 71 / 255)
case .ollama:
Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal
case .synthetic:
diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
index abd65ecf77..de7989e3ed 100644
--- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift
+++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
@@ -24,6 +24,7 @@ struct ProviderIconResourcesTests {
"copilot",
"crof",
"commandcode",
+ "t3chat",
"kimi",
"bedrock",
"elevenlabs",
diff --git a/Tests/CodexBarTests/T3ChatUsageFetcherTests.swift b/Tests/CodexBarTests/T3ChatUsageFetcherTests.swift
new file mode 100644
index 0000000000..83e69ef33c
--- /dev/null
+++ b/Tests/CodexBarTests/T3ChatUsageFetcherTests.swift
@@ -0,0 +1,295 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct T3ChatUsageFetcherTests {
+ private struct StubClaudeFetcher: ClaudeUsageFetching {
+ func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
+ throw ClaudeUsageError.parseFailed("stub")
+ }
+
+ func debugRawProbe(model _: String) async -> String {
+ "stub"
+ }
+
+ func detectVersion() -> String? {
+ nil
+ }
+ }
+
+ private static let now = Date(timeIntervalSince1970: 1_778_000_000)
+ // 2026-05-21T12:23:36Z, the usage-window reset that must not drive overage reset display.
+ private static let billingNextResetMilliseconds = 1_779_366_216_920
+ // 2026-06-06T16:23:29Z, the subscription period end used for overage reset display.
+ private static let subscriptionPeriodEndSeconds = 1_780_763_009
+ private static let subscriptionPeriodEndMilliseconds = Self.subscriptionPeriodEndSeconds * 1000
+
+ private static let sampleResponse = [
+ #"{"json":{"0":[[0],[null,0,0]]}}"#,
+ #"{"json":[0,0,[[{"result":0}],["result",0,1]]]}"#,
+ #"{"json":[1,0,[[{"data":0}],["data",0,2]]]}"#,
+ #"{"json":[2,0,[[{"subTier":"pro","subscription":{"# +
+ #""productId":"pro","productName":"pro","status":"active","# +
+ #""currentPeriodStart":1778084609000,"currentPeriodEnd":1780763009000,"# +
+ #""canceledAt":null,"trialEndsAt":null},"lifetimeBalance":0,"usageBand":"max","# +
+ #""billingNextResetAt":1779366216920,"usageFourHourPercentage":12.5,"# +
+ #""usageMonthPercentage":34.25,"usageFourHourNextResetAt":1779366216920,"# +
+ #""usagePeriodPercentage":44,"usageWindowNextResetAt":1779366216920}]]]}"#,
+ ].joined(separator: "\n")
+
+ @Test
+ func `parses customer data from json lines response`() throws {
+ let snapshot = try T3ChatUsageParser.parseJSONLines(Self.sampleResponse, now: Self.now)
+
+ #expect(snapshot.customerData.subTier == "pro")
+ #expect(snapshot.customerData.usageBand == "max")
+ #expect(snapshot.customerData.usageFourHourPercentage == 12.5)
+ #expect(snapshot.customerData.usageMonthPercentage == 34.25)
+ #expect(snapshot.customerData.subscription?.status == "active")
+ }
+
+ @Test
+ func `maps customer data to base and overage windows`() throws {
+ let usage = try T3ChatUsageParser.parseJSONLines(Self.sampleResponse, now: Self.now)
+ .toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 12.5)
+ #expect(usage.primary?.windowMinutes == 240)
+ #expect(usage.primary?.resetDescription == "Base - max")
+ #expect(usage.secondary?.usedPercent == 34.25)
+ #expect(usage.secondary?.resetDescription == "Overage")
+ #expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.subscriptionPeriodEndSeconds)
+ #expect(usage.identity?.providerID == .t3chat)
+ #expect(usage.identity?.loginMethod == "Pro")
+ }
+
+ @Test
+ func `falls back to usage period percentage when month percentage is absent`() throws {
+ let response = """
+ {"json":[2,0,[[{"subTier":"free","usageFourHourPercentage":5,"usagePeriodPercentage":65}]]]}
+ """
+ let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now)
+ .toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 5)
+ #expect(usage.secondary?.usedPercent == 65)
+ }
+
+ @Test
+ func `overage reset ignores billing next reset`() throws {
+ let response = Self.customerDataResponse(
+ #"{"usageMonthPercentage":20,"billingNextResetAt":\#(Self.billingNextResetMilliseconds)}"#)
+ let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now)
+ .toUsageSnapshot()
+
+ #expect(usage.secondary?.usedPercent == 20)
+ #expect(usage.secondary?.resetsAt == nil)
+ }
+
+ @Test
+ func `overage reset uses subscription current period end`() throws {
+ let currentPeriodEnd = Self.subscriptionPeriodEndMilliseconds
+ let response = Self.customerDataResponse(
+ #"{"usageMonthPercentage":20,"subscription":{"currentPeriodEnd":\#(currentPeriodEnd)}}"#)
+ let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now)
+ .toUsageSnapshot()
+
+ #expect(usage.secondary?.usedPercent == 20)
+ #expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.subscriptionPeriodEndSeconds)
+ }
+
+ @Test
+ func `fetch sends trpc headers and cookie`() async throws {
+ let stub = ProviderHTTPTransportStub { request in
+ #expect(request.url?.host == "t3.chat")
+ #expect(request.url?.path == "/api/trpc/getCustomerData")
+ #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc")
+ #expect(request.value(forHTTPHeaderField: "trpc-accept") == "application/jsonl")
+ #expect(request.value(forHTTPHeaderField: "x-trpc-source") == "web-client")
+ #expect(request.value(forHTTPHeaderField: "Sec-Fetch-Site") == "same-origin")
+ #expect(request.url?.query?.contains("batch=1") == true)
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil)!
+ return (Data(Self.sampleResponse.utf8), response)
+ }
+
+ let snapshot = try await T3ChatUsageFetcher.fetchCustomerData(
+ cookieHeader: "session=abc",
+ now: Self.now,
+ transport: stub)
+
+ #expect(snapshot.customerData.planName == "Pro")
+ }
+
+ @Test
+ func `full curl capture forwards browser fingerprint headers`() async throws {
+ let curl = """
+ curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\
+ -H 'User-Agent: Mozilla/5.0 Firefox/151.0' \\
+ --header "Referer: https://t3.chat/settings/customization" \\
+ -H 'trpc-accept: application/jsonl' \\
+ -H 'x-trpc-source: web-client' \\
+ -H 'x-trpc-batch: true' \\
+ -H 'X-Deployment-Id: dpl_test' \\
+ -H 'x-client-context: eyJjbGllbnQiOnsidmVyc2lvbiI6IjEuMTIuNCJ9fQ==' \\
+ -H 'Cookie: session=abc'
+ """
+ let stub = ProviderHTTPTransportStub { request in
+ #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc")
+ #expect(request.value(forHTTPHeaderField: "User-Agent") == "Mozilla/5.0 Firefox/151.0")
+ #expect(request.value(forHTTPHeaderField: "Referer") == "https://t3.chat/settings/customization")
+ #expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test")
+ #expect(request.value(forHTTPHeaderField: "x-client-context") ==
+ "eyJjbGllbnQiOnsidmVyc2lvbiI6IjEuMTIuNCJ9fQ==")
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil)!
+ return (Data(Self.sampleResponse.utf8), response)
+ }
+
+ let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0))
+ _ = try await fetcher.fetch(
+ cookieHeaderOverride: curl,
+ now: Self.now,
+ transport: stub)
+ }
+
+ @Test
+ func `curl capture forwards ansi quoted and equals header forms`() async throws {
+ let curl = """
+ curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\
+ --header=$'User-Agent: Browser\\'s Agent' \\
+ --header 'X-Deployment-Id: dpl_test' \\
+ -H 'Cookie: session=abc'
+ """
+ let stub = ProviderHTTPTransportStub { request in
+ #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc")
+ #expect(request.value(forHTTPHeaderField: "User-Agent") == "Browser's Agent")
+ #expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test")
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil)!
+ return (Data(Self.sampleResponse.utf8), response)
+ }
+
+ let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0))
+ _ = try await fetcher.fetch(
+ cookieHeaderOverride: curl,
+ now: Self.now,
+ transport: stub)
+ }
+
+ @Test
+ func `full curl capture extracts cookie from long header form`() async throws {
+ let curl = """
+ curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\
+ --compressed \\
+ --header "Referer: https://t3.chat/settings/customization" \\
+ --header "Cookie: session=abc; cf_clearance=token" \\
+ --header "X-Deployment-Id: dpl_test"
+ """
+ let stub = ProviderHTTPTransportStub { request in
+ #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc; cf_clearance=token")
+ #expect(request.value(forHTTPHeaderField: "Referer") == "https://t3.chat/settings/customization")
+ #expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test")
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil)!
+ return (Data(Self.sampleResponse.utf8), response)
+ }
+
+ let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0))
+ _ = try await fetcher.fetch(
+ cookieHeaderOverride: curl,
+ now: Self.now,
+ transport: stub)
+ }
+
+ @Test
+ func `manual strategy accepts full curl capture`() async {
+ let curl = """
+ curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\
+ --header "Referer: https://t3.chat/settings/customization" \\
+ --header "Cookie: session=abc; cf_clearance=token" \\
+ --header "X-Deployment-Id: dpl_test"
+ """
+ let settings = ProviderSettingsSnapshot.make(
+ t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings(
+ cookieSource: .manual,
+ manualCookieHeader: curl))
+
+ #expect(await T3ChatWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)))
+ }
+
+ @Test
+ func `unauthorized response is invalid credentials`() async throws {
+ let stub = ProviderHTTPTransportStub { request in
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: 401,
+ httpVersion: nil,
+ headerFields: nil)!
+ return (Data("unauthorized".utf8), response)
+ }
+
+ await #expect {
+ _ = try await T3ChatUsageFetcher.fetchCustomerData(
+ cookieHeader: "session=abc",
+ now: Self.now,
+ transport: stub)
+ } throws: { error in
+ guard case T3ChatUsageError.invalidCredentials = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func `vercel challenge response asks for full curl capture`() async throws {
+ let stub = ProviderHTTPTransportStub { request in
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: 429,
+ httpVersion: nil,
+ headerFields: ["x-vercel-mitigated": "challenge"])!
+ return (Data("checkpoint".utf8), response)
+ }
+
+ await #expect {
+ _ = try await T3ChatUsageFetcher.fetchCustomerData(
+ cookieHeader: "session=abc",
+ now: Self.now,
+ transport: stub)
+ } throws: { error in
+ guard case T3ChatUsageError.vercelChallenge = error else { return false }
+ return true
+ }
+ }
+
+ private static func customerDataResponse(_ customerDataJSON: String) -> String {
+ #"{"json":[2,0,[[\#(customerDataJSON)]]]}"# + "\n"
+ }
+
+ private static func makeContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext {
+ ProviderFetchContext(
+ runtime: .app,
+ sourceMode: .auto,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: [:],
+ settings: settings,
+ fetcher: UsageFetcher(environment: [:]),
+ claudeFetcher: StubClaudeFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0))
+ }
+}
diff --git a/docs/providers.md b/docs/providers.md
index 17a3d86fad..7f1d0f6baf 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -8,7 +8,7 @@ read_when:
# Providers
-CodexBar currently registers 46 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or
+CodexBar currently registers 47 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or
OpenCode vs OpenCode Go, because the auth source and quota shape differ.
## Fetch strategies (current)
@@ -44,6 +44,7 @@ headers, source selection, provider ordering, and token accounts are stored in `
| Augment | `auggie` CLI first, then browser-cookie web fallback (`cli`, `web`). |
| JetBrains AI | Local XML quota file (`local`). |
| Amp | Web settings page via browser cookies (`web`). |
+| T3 Chat | Web tRPC customer-data endpoint via browser cookies (`web`). |
| Warp | API token (config/env) → GraphQL request limits (`api`). |
| ElevenLabs | API key from config/env → subscription usage API (`api`). |
| Windsurf | Web session bundle from browser localStorage (`web`) → local SQLite cache (`local`). |
@@ -238,6 +239,12 @@ headers, source selection, provider ordering, and token accounts are stored in `
- Status: none yet.
- Details: `docs/amp.md`.
+## T3 Chat
+- Web tRPC endpoint (`https://t3.chat/api/trpc/getCustomerData`) via browser cookies.
+- Parses JSONL response lines and extracts customer data from the embedded tRPC payload.
+- Shows the 4-hour Base bucket and monthly Overage bucket documented in the T3 Chat FAQ.
+- Status: none yet.
+
## Ollama
- Web settings page (`https://ollama.com/settings`) via browser cookies.
- Parses Cloud Usage plan badge, session/weekly usage, and reset timestamps.