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 } }