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.