1271 lines
48 KiB
Swift
1271 lines
48 KiB
Swift
import Foundation
|
|
import SweetCookieKit
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
enum ClaudeWebHTTPTransport {
|
|
#if DEBUG
|
|
@TaskLocal static var overrideForTesting: (any ProviderHTTPTransport)?
|
|
#endif
|
|
|
|
static var current: any ProviderHTTPTransport {
|
|
#if DEBUG
|
|
if let override = self.overrideForTesting {
|
|
return override
|
|
}
|
|
#endif
|
|
return ProviderHTTPClient.shared
|
|
}
|
|
}
|
|
|
|
enum ClaudeWebSessionKeyImport {
|
|
#if DEBUG
|
|
@TaskLocal static var overrideForTesting: ClaudeWebAPIFetcher.SessionKeyInfo?
|
|
#endif
|
|
|
|
static var currentOverride: ClaudeWebAPIFetcher.SessionKeyInfo? {
|
|
#if DEBUG
|
|
self.overrideForTesting
|
|
#else
|
|
nil
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private actor ClaudeWebBrowserFetchGate {
|
|
private struct Waiter {
|
|
let id: UUID
|
|
let continuation: CheckedContinuation<Bool, Never>
|
|
}
|
|
|
|
private var ownerID: UUID?
|
|
private var waiters: [Waiter] = []
|
|
|
|
func acquire(id: UUID) async -> Bool {
|
|
if Task.isCancelled { return false }
|
|
guard self.ownerID != nil else {
|
|
self.ownerID = id
|
|
return true
|
|
}
|
|
return await withCheckedContinuation { continuation in
|
|
self.waiters.append(Waiter(id: id, continuation: continuation))
|
|
}
|
|
}
|
|
|
|
func cancel(id: UUID) {
|
|
if self.ownerID == id { return }
|
|
guard let index = self.waiters.firstIndex(where: { $0.id == id }) else { return }
|
|
let waiter = self.waiters.remove(at: index)
|
|
waiter.continuation.resume(returning: false)
|
|
}
|
|
|
|
func release(id: UUID) {
|
|
guard self.ownerID == id else { return }
|
|
guard !self.waiters.isEmpty else {
|
|
self.ownerID = nil
|
|
return
|
|
}
|
|
let waiter = self.waiters.removeFirst()
|
|
self.ownerID = waiter.id
|
|
waiter.continuation.resume(returning: true)
|
|
}
|
|
}
|
|
|
|
private enum ClaudeWebBrowserFetchSerialization {
|
|
private static let gate = ClaudeWebBrowserFetchGate()
|
|
|
|
static func run<T>(_ operation: () async throws -> T) async throws -> T {
|
|
let id = UUID()
|
|
let acquired = await withTaskCancellationHandler {
|
|
await self.gate.acquire(id: id)
|
|
} onCancel: {
|
|
Task { await self.gate.cancel(id: id) }
|
|
}
|
|
guard acquired else { throw CancellationError() }
|
|
do {
|
|
try Task.checkCancellation()
|
|
let value = try await operation()
|
|
await self.gate.release(id: id)
|
|
return value
|
|
} catch {
|
|
await self.gate.release(id: id)
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetches Claude usage data directly from the claude.ai API using browser session cookies.
|
|
///
|
|
/// This approach mirrors what Claude Usage Tracker does, but automatically extracts the session key
|
|
/// from browser cookies instead of requiring manual setup.
|
|
///
|
|
/// API endpoints used:
|
|
/// - `GET https://claude.ai/api/organizations` → get org UUID
|
|
/// - `GET https://claude.ai/api/organizations/{org_id}/usage` → usage percentages + reset times
|
|
public enum ClaudeWebAPIFetcher {
|
|
private static let baseURL = "https://claude.ai/api"
|
|
private static let maxProbeBytes = 200_000
|
|
#if os(macOS)
|
|
private static let cookieClient = BrowserCookieClient()
|
|
private static let cookieImportOrder: BrowserCookieImportOrder =
|
|
ProviderDefaults.metadata[.claude]?.browserCookieOrder ?? Browser.defaultImportOrder
|
|
#else
|
|
private static let cookieImportOrder: BrowserCookieImportOrder = []
|
|
#endif
|
|
|
|
public struct OrganizationInfo: Sendable {
|
|
public let id: String
|
|
public let name: String?
|
|
|
|
public init(id: String, name: String?) {
|
|
self.id = id
|
|
self.name = name
|
|
}
|
|
}
|
|
|
|
public struct SessionKeyInfo: Sendable {
|
|
public let key: String
|
|
public let sourceLabel: String
|
|
public let cookieCount: Int
|
|
|
|
public init(key: String, sourceLabel: String, cookieCount: Int) {
|
|
self.key = key
|
|
self.sourceLabel = sourceLabel
|
|
self.cookieCount = cookieCount
|
|
}
|
|
}
|
|
|
|
public enum FetchError: LocalizedError, Sendable {
|
|
case noSessionKeyFound
|
|
case invalidSessionKey
|
|
case notSupportedOnThisPlatform
|
|
case networkError(Error)
|
|
case invalidResponse
|
|
case unauthorized
|
|
case serverError(statusCode: Int)
|
|
case noOrganization
|
|
case organizationNotFound(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .noSessionKeyFound:
|
|
"No Claude session key found in browser cookies."
|
|
case .invalidSessionKey:
|
|
"Invalid Claude session key format."
|
|
case .notSupportedOnThisPlatform:
|
|
"Claude web fetching is only supported on macOS."
|
|
case let .networkError(error):
|
|
"Network error: \(error.localizedDescription)"
|
|
case .invalidResponse:
|
|
"Invalid response from Claude API."
|
|
case .unauthorized:
|
|
"Sign in to claude.ai (or refresh Claude cookies) to load usage data."
|
|
case let .serverError(code):
|
|
"Claude API error: HTTP \(code)"
|
|
case .noOrganization:
|
|
"No Claude organization found for this account."
|
|
case let .organizationNotFound(id):
|
|
"Claude organization '\(id)' was not found for this session."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Claude usage data from the API
|
|
public struct WebUsageData: Sendable {
|
|
public let sessionPercentUsed: Double
|
|
public let sessionResetsAt: Date?
|
|
public let weeklyPercentUsed: Double?
|
|
public let weeklyResetsAt: Date?
|
|
public let opusPercentUsed: Double?
|
|
public let extraRateWindows: [NamedRateWindow]
|
|
public let extraUsageCost: ProviderCostSnapshot?
|
|
public let accountOrganization: String?
|
|
public let accountEmail: String?
|
|
public let loginMethod: String?
|
|
/// Whether the API reported a `five_hour` session object. When `false` (the API sent
|
|
/// `five_hour: null`, as enterprise/credit accounts with no live session do), `sessionPercentUsed`
|
|
/// is the synthesized `0` placeholder rather than a real reading. Distinguishing this from a real
|
|
/// session that happens to be at `0%` (with or without a reset) lets lane classifiers drop the
|
|
/// placeholder without hiding a genuine empty session.
|
|
public let hasLiveSessionWindow: Bool
|
|
|
|
public init(
|
|
sessionPercentUsed: Double,
|
|
sessionResetsAt: Date?,
|
|
weeklyPercentUsed: Double?,
|
|
weeklyResetsAt: Date?,
|
|
opusPercentUsed: Double?,
|
|
extraRateWindows: [NamedRateWindow],
|
|
extraUsageCost: ProviderCostSnapshot?,
|
|
accountOrganization: String?,
|
|
accountEmail: String?,
|
|
loginMethod: String?,
|
|
hasLiveSessionWindow: Bool = true)
|
|
{
|
|
self.sessionPercentUsed = sessionPercentUsed
|
|
self.sessionResetsAt = sessionResetsAt
|
|
self.weeklyPercentUsed = weeklyPercentUsed
|
|
self.weeklyResetsAt = weeklyResetsAt
|
|
self.opusPercentUsed = opusPercentUsed
|
|
self.extraRateWindows = extraRateWindows
|
|
self.extraUsageCost = extraUsageCost
|
|
self.accountOrganization = accountOrganization
|
|
self.accountEmail = accountEmail
|
|
self.loginMethod = loginMethod
|
|
self.hasLiveSessionWindow = hasLiveSessionWindow
|
|
}
|
|
}
|
|
|
|
public struct ProbeResult: Sendable {
|
|
public let url: String
|
|
public let statusCode: Int?
|
|
public let contentType: String?
|
|
public let topLevelKeys: [String]
|
|
public let emails: [String]
|
|
public let planHints: [String]
|
|
public let notableFields: [String]
|
|
public let bodyPreview: String?
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
#if os(macOS)
|
|
|
|
/// Attempts to fetch Claude usage data using cookies extracted from browsers.
|
|
/// Tries browser cookies using the standard import order.
|
|
public static func fetchUsage(
|
|
browserDetection: BrowserDetection,
|
|
targetOrganizationID: String? = nil,
|
|
logger: ((String) -> Void)? = nil) async throws -> WebUsageData
|
|
{
|
|
try await ClaudeWebBrowserFetchSerialization.run {
|
|
try await self.fetchUsageSerialized(
|
|
browserDetection: browserDetection,
|
|
targetOrganizationID: targetOrganizationID,
|
|
logger: logger)
|
|
}
|
|
}
|
|
|
|
public static func fetchUsage(
|
|
cookieHeader: String,
|
|
targetOrganizationID: String? = nil,
|
|
logger: ((String) -> Void)? = nil) async throws -> WebUsageData
|
|
{
|
|
let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") }
|
|
let sessionInfo = try self.sessionKeyInfo(cookieHeader: cookieHeader)
|
|
log("Using manual session key (\(sessionInfo.cookieCount) cookies)")
|
|
return try await self.fetchUsage(
|
|
using: sessionInfo,
|
|
targetOrganizationID: targetOrganizationID,
|
|
logger: log)
|
|
}
|
|
|
|
public static func fetchUsage(
|
|
using sessionKeyInfo: SessionKeyInfo,
|
|
targetOrganizationID: String? = nil,
|
|
logger: ((String) -> Void)? = nil) async throws -> WebUsageData
|
|
{
|
|
try await self.fetchUsage(
|
|
using: sessionKeyInfo,
|
|
targetOrganizationID: targetOrganizationID,
|
|
logger: logger,
|
|
cacheSourceLabel: nil,
|
|
expectedCacheObservation: .authoritative(nil))
|
|
}
|
|
|
|
private static func fetchUsageAndRenewCache(
|
|
cachedEntry: CookieHeaderCache.Entry,
|
|
targetOrganizationID: String?,
|
|
logger: ((String) -> Void)? = nil) async throws -> WebUsageData
|
|
{
|
|
let sessionInfo = try self.sessionKeyInfo(cookieHeader: cachedEntry.cookieHeader)
|
|
return try await self.fetchUsage(
|
|
using: sessionInfo,
|
|
targetOrganizationID: targetOrganizationID,
|
|
logger: logger,
|
|
cacheSourceLabel: cachedEntry.sourceLabel,
|
|
expectedCacheObservation: .authoritative(cachedEntry))
|
|
}
|
|
|
|
private static func fetchUsage(
|
|
using sessionKeyInfo: SessionKeyInfo,
|
|
targetOrganizationID: String?,
|
|
logger: ((String) -> Void)?,
|
|
cacheSourceLabel: String?,
|
|
expectedCacheObservation: CookieHeaderCache.ConditionalMutationObservation,
|
|
persistInitialSessionKey: Bool = false) async throws -> WebUsageData
|
|
{
|
|
let log: (String) -> Void = { msg in logger?(msg) }
|
|
let sessionKey = sessionKeyInfo.key
|
|
let renewalTracker = ClaudeWebSessionKeyRenewalTracker(initialSessionKey: sessionKey)
|
|
|
|
// Fetch organization info
|
|
let organization = try await fetchOrganizationInfo(
|
|
sessionKey: sessionKey,
|
|
targetOrganizationID: targetOrganizationID,
|
|
logger: log,
|
|
renewalTracker: renewalTracker)
|
|
log("Organization resolved")
|
|
|
|
var usage = try await fetchUsageData(
|
|
orgId: organization.id,
|
|
sessionKey: renewalTracker.sessionKey,
|
|
logger: log,
|
|
renewalTracker: renewalTracker)
|
|
if usage.extraUsageCost == nil,
|
|
let extra = await ClaudeWebExtraUsageCost.fetch(
|
|
baseURL: Self.baseURL,
|
|
orgId: organization.id,
|
|
sessionKey: renewalTracker.sessionKey,
|
|
logger: log,
|
|
renewalTracker: renewalTracker)
|
|
{
|
|
usage = WebUsageData(
|
|
sessionPercentUsed: usage.sessionPercentUsed,
|
|
sessionResetsAt: usage.sessionResetsAt,
|
|
weeklyPercentUsed: usage.weeklyPercentUsed,
|
|
weeklyResetsAt: usage.weeklyResetsAt,
|
|
opusPercentUsed: usage.opusPercentUsed,
|
|
extraRateWindows: usage.extraRateWindows,
|
|
extraUsageCost: extra,
|
|
accountOrganization: usage.accountOrganization,
|
|
accountEmail: usage.accountEmail,
|
|
loginMethod: usage.loginMethod,
|
|
hasLiveSessionWindow: usage.hasLiveSessionWindow)
|
|
}
|
|
if let account = await fetchAccountInfo(
|
|
sessionKey: renewalTracker.sessionKey,
|
|
orgId: organization.id,
|
|
logger: log,
|
|
renewalTracker: renewalTracker)
|
|
{
|
|
usage = WebUsageData(
|
|
sessionPercentUsed: usage.sessionPercentUsed,
|
|
sessionResetsAt: usage.sessionResetsAt,
|
|
weeklyPercentUsed: usage.weeklyPercentUsed,
|
|
weeklyResetsAt: usage.weeklyResetsAt,
|
|
opusPercentUsed: usage.opusPercentUsed,
|
|
extraRateWindows: usage.extraRateWindows,
|
|
extraUsageCost: usage.extraUsageCost,
|
|
accountOrganization: usage.accountOrganization,
|
|
accountEmail: account.email,
|
|
loginMethod: account.loginMethod,
|
|
hasLiveSessionWindow: usage.hasLiveSessionWindow)
|
|
}
|
|
if usage.accountOrganization == nil, let name = organization.name {
|
|
usage = WebUsageData(
|
|
sessionPercentUsed: usage.sessionPercentUsed,
|
|
sessionResetsAt: usage.sessionResetsAt,
|
|
weeklyPercentUsed: usage.weeklyPercentUsed,
|
|
weeklyResetsAt: usage.weeklyResetsAt,
|
|
opusPercentUsed: usage.opusPercentUsed,
|
|
extraRateWindows: usage.extraRateWindows,
|
|
extraUsageCost: usage.extraUsageCost,
|
|
accountOrganization: name,
|
|
accountEmail: usage.accountEmail,
|
|
loginMethod: usage.loginMethod,
|
|
hasLiveSessionWindow: usage.hasLiveSessionWindow)
|
|
}
|
|
if let cacheSourceLabel {
|
|
self.persistSessionKeyIfNeeded(
|
|
source: (sessionKey, cacheSourceLabel),
|
|
renewedCookieHeader: renewalTracker.renewedCookieHeader,
|
|
expectedCacheObservation: expectedCacheObservation,
|
|
persistInitialSessionKey: persistInitialSessionKey,
|
|
logger: log)
|
|
}
|
|
return usage
|
|
}
|
|
|
|
/// Probes a list of endpoints using the current claude.ai session cookies.
|
|
/// - Parameters:
|
|
/// - endpoints: Absolute URLs or "/api/..." paths. Supports "{orgId}" placeholder.
|
|
/// - includePreview: When true, includes a truncated response preview in results.
|
|
public static func probeEndpoints(
|
|
_ endpoints: [String],
|
|
browserDetection: BrowserDetection,
|
|
includePreview: Bool = false,
|
|
logger: ((String) -> Void)? = nil) async throws -> [ProbeResult]
|
|
{
|
|
let log: (String) -> Void = { msg in logger?("[claude-probe] \(msg)") }
|
|
let sessionInfo = try extractSessionKeyInfo(browserDetection: browserDetection, logger: log)
|
|
let sessionKey = sessionInfo.key
|
|
let organization = try? await fetchOrganizationInfo(sessionKey: sessionKey, logger: log)
|
|
let expanded = endpoints.map { endpoint -> String in
|
|
var url = endpoint
|
|
if let orgId = organization?.id {
|
|
url = url.replacingOccurrences(of: "{orgId}", with: orgId)
|
|
}
|
|
if url.hasPrefix("/") {
|
|
url = "https://claude.ai\(url)"
|
|
}
|
|
return url
|
|
}
|
|
|
|
var results: [ProbeResult] = []
|
|
results.reserveCapacity(expanded.count)
|
|
|
|
for endpoint in expanded {
|
|
guard let url = URL(string: endpoint) else { continue }
|
|
var request = URLRequest(url: url)
|
|
request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
|
|
request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 20
|
|
|
|
do {
|
|
let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request)
|
|
let http = response as? HTTPURLResponse
|
|
let contentType = http?.allHeaderFields["Content-Type"] as? String
|
|
let truncated = data.prefix(Self.maxProbeBytes)
|
|
let body = String(data: truncated, encoding: .utf8) ?? ""
|
|
|
|
let parsed = Self.parseProbeBody(data: data, fallbackText: body, contentType: contentType)
|
|
let preview = includePreview ? parsed.preview : nil
|
|
|
|
results.append(ProbeResult(
|
|
url: endpoint,
|
|
statusCode: http?.statusCode,
|
|
contentType: contentType,
|
|
topLevelKeys: parsed.keys,
|
|
emails: parsed.emails,
|
|
planHints: parsed.planHints,
|
|
notableFields: parsed.notableFields,
|
|
bodyPreview: preview))
|
|
} catch {
|
|
results.append(ProbeResult(
|
|
url: endpoint,
|
|
statusCode: nil,
|
|
contentType: nil,
|
|
topLevelKeys: [],
|
|
emails: [],
|
|
planHints: [],
|
|
notableFields: [],
|
|
bodyPreview: "Error: \(error.localizedDescription)"))
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
/// Checks if we can find a Claude session key in browser cookies without making API calls.
|
|
public static func hasSessionKey(browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> Bool {
|
|
if let cached = CookieHeaderCache.load(provider: .claude),
|
|
self.hasSessionKey(cookieHeader: cached.cookieHeader)
|
|
{
|
|
return true
|
|
}
|
|
do {
|
|
_ = try self.sessionKeyInfo(browserDetection: browserDetection, logger: logger)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
public static func hasSessionKey(cookieHeader: String?) -> Bool {
|
|
guard let cookieHeader else { return false }
|
|
return (try? self.sessionKeyInfo(cookieHeader: cookieHeader)) != nil
|
|
}
|
|
|
|
public static func sessionKeyInfo(
|
|
browserDetection: BrowserDetection,
|
|
logger: ((String) -> Void)? = nil) throws -> SessionKeyInfo
|
|
{
|
|
try self.extractSessionKeyInfo(browserDetection: browserDetection, logger: logger)
|
|
}
|
|
|
|
public static func sessionKeyInfo(cookieHeader: String) throws -> SessionKeyInfo {
|
|
let pairs = CookieHeaderNormalizer.pairs(from: cookieHeader)
|
|
if let sessionKey = self.findSessionKey(in: pairs) {
|
|
return SessionKeyInfo(
|
|
key: sessionKey,
|
|
sourceLabel: "Manual",
|
|
cookieCount: pairs.count)
|
|
}
|
|
throw FetchError.noSessionKeyFound
|
|
}
|
|
|
|
// MARK: - Session Key Extraction
|
|
|
|
private static func extractSessionKeyInfo(
|
|
browserDetection: BrowserDetection,
|
|
logger: ((String) -> Void)? = nil) throws -> SessionKeyInfo
|
|
{
|
|
if let override = ClaudeWebSessionKeyImport.currentOverride { return override }
|
|
let log: (String) -> Void = { msg in logger?(msg) }
|
|
|
|
let cookieDomains = ["claude.ai"]
|
|
|
|
// Filter to cookie-eligible browsers to avoid unnecessary keychain prompts
|
|
let installedBrowsers = Self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
|
|
for browserSource in installedBrowsers {
|
|
do {
|
|
let query = BrowserCookieQuery(domains: cookieDomains)
|
|
let sources = try Self.cookieClient.codexBarRecords(
|
|
matching: query,
|
|
in: browserSource,
|
|
logger: log)
|
|
for source in sources {
|
|
if let sessionKey = findSessionKey(in: source.records.map { record in
|
|
(name: record.name, value: record.value)
|
|
}) {
|
|
log("Found sessionKey in \(source.label)")
|
|
return SessionKeyInfo(
|
|
key: sessionKey,
|
|
sourceLabel: source.label,
|
|
cookieCount: source.records.count)
|
|
}
|
|
}
|
|
} catch {
|
|
BrowserCookieAccessGate.recordIfNeeded(error)
|
|
log("\(browserSource.displayName) cookie load failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
throw FetchError.noSessionKeyFound
|
|
}
|
|
|
|
private static func findSessionKey(in cookies: [(name: String, value: String)]) -> String? {
|
|
for cookie in cookies where cookie.name == "sessionKey" {
|
|
let value = cookie.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
// Validate it looks like a Claude session key
|
|
if value.hasPrefix("sk-ant-") {
|
|
return value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - API Calls
|
|
|
|
private static func fetchOrganizationInfo(
|
|
sessionKey: String,
|
|
targetOrganizationID: String? = nil,
|
|
logger: ((String) -> Void)? = nil,
|
|
renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> OrganizationInfo
|
|
{
|
|
let url = URL(string: "\(baseURL)/organizations")!
|
|
var request = URLRequest(url: url)
|
|
request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 15
|
|
|
|
let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw FetchError.invalidResponse
|
|
}
|
|
renewalTracker?.observe(response: httpResponse)
|
|
|
|
logger?("Organizations API status: \(httpResponse.statusCode)")
|
|
|
|
switch httpResponse.statusCode {
|
|
case 200:
|
|
return try self.parseOrganizationResponse(data, targetOrganizationID: targetOrganizationID)
|
|
case 401, 403:
|
|
throw FetchError.unauthorized
|
|
default:
|
|
throw FetchError.serverError(statusCode: httpResponse.statusCode)
|
|
}
|
|
}
|
|
|
|
private static func fetchUsageData(
|
|
orgId: String,
|
|
sessionKey: String,
|
|
logger: ((String) -> Void)? = nil,
|
|
renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> WebUsageData
|
|
{
|
|
let url = URL(string: "\(baseURL)/organizations/\(orgId)/usage")!
|
|
var request = URLRequest(url: url)
|
|
request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 15
|
|
|
|
let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw FetchError.invalidResponse
|
|
}
|
|
renewalTracker?.observe(response: httpResponse)
|
|
|
|
logger?("Usage API status: \(httpResponse.statusCode)")
|
|
|
|
switch httpResponse.statusCode {
|
|
case 200:
|
|
return try self.parseUsageResponse(data, logger: logger)
|
|
case 401, 403:
|
|
throw FetchError.unauthorized
|
|
default:
|
|
throw FetchError.serverError(statusCode: httpResponse.statusCode)
|
|
}
|
|
}
|
|
|
|
private static func parseUsageResponse(_ data: Data, logger: ((String) -> Void)? = nil) throws -> WebUsageData {
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw FetchError.invalidResponse
|
|
}
|
|
|
|
// Parse five_hour (session) usage
|
|
var sessionPercent: Double?
|
|
var sessionResets: Date?
|
|
let fiveHour = json["five_hour"] as? [String: Any]
|
|
if let fiveHour {
|
|
sessionPercent = Self.percentValue(from: fiveHour["utilization"])
|
|
if let resetsAt = fiveHour["resets_at"] as? String {
|
|
sessionResets = self.parseISO8601Date(resetsAt)
|
|
}
|
|
}
|
|
// Enterprise/credit-based accounts return null for five_hour; treat as 0% rather than an error.
|
|
// Track the object's presence so a real 0% session (with or without a reset) is not mistaken for
|
|
// the synthesized null-session placeholder downstream.
|
|
let resolvedSessionPercent = sessionPercent ?? 0.0
|
|
let hasLiveSessionWindow = fiveHour != nil
|
|
|
|
// Parse seven_day (weekly) usage
|
|
var weeklyPercent: Double?
|
|
var weeklyResets: Date?
|
|
if let sevenDay = json["seven_day"] as? [String: Any] {
|
|
weeklyPercent = Self.percentValue(from: sevenDay["utilization"])
|
|
if let resetsAt = sevenDay["resets_at"] as? String {
|
|
weeklyResets = self.parseISO8601Date(resetsAt)
|
|
}
|
|
}
|
|
|
|
// Parse seven_day_sonnet (preferred) / seven_day_opus usage
|
|
var opusPercent: Double?
|
|
if let sevenDaySonnet = json["seven_day_sonnet"] as? [String: Any] {
|
|
opusPercent = Self.percentValue(from: sevenDaySonnet["utilization"])
|
|
} else if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] {
|
|
opusPercent = Self.percentValue(from: sevenDayOpus["utilization"])
|
|
}
|
|
let extraRateParse = ClaudeWebExtraRateWindowParser.parse(from: json)
|
|
if let sourceKey = extraRateParse.sourceKeys["claude-routines"] {
|
|
logger?("Usage API extra window key matched: routines=\(sourceKey)")
|
|
}
|
|
let extraUsageCost = ClaudeWebExtraUsageCost.parse(from: json["extra_usage"])
|
|
|
|
return WebUsageData(
|
|
sessionPercentUsed: resolvedSessionPercent,
|
|
sessionResetsAt: sessionResets,
|
|
weeklyPercentUsed: weeklyPercent,
|
|
weeklyResetsAt: weeklyResets,
|
|
opusPercentUsed: opusPercent,
|
|
extraRateWindows: extraRateParse.windows,
|
|
extraUsageCost: extraUsageCost,
|
|
accountOrganization: nil,
|
|
accountEmail: nil,
|
|
loginMethod: nil,
|
|
hasLiveSessionWindow: hasLiveSessionWindow)
|
|
}
|
|
|
|
private static func percentValue(from value: Any?) -> Double? {
|
|
if let intValue = value as? Int {
|
|
return Double(intValue)
|
|
}
|
|
if let doubleValue = value as? Double {
|
|
return doubleValue
|
|
}
|
|
return nil
|
|
}
|
|
|
|
#if DEBUG
|
|
|
|
// MARK: - Test hooks (DEBUG-only)
|
|
|
|
public static func _parseUsageResponseForTesting(_ data: Data) throws -> WebUsageData {
|
|
try self.parseUsageResponse(data)
|
|
}
|
|
|
|
public static func _parseOrganizationsResponseForTesting(
|
|
_ data: Data,
|
|
targetOrganizationID: String? = nil) throws -> OrganizationInfo
|
|
{
|
|
try self.parseOrganizationResponse(data, targetOrganizationID: targetOrganizationID)
|
|
}
|
|
|
|
public static func _parseOverageSpendLimitForTesting(_ data: Data) -> ProviderCostSnapshot? {
|
|
ClaudeWebExtraUsageCost.parseOverageSpendLimit(data)
|
|
}
|
|
|
|
public static func _parseAccountInfoForTesting(_ data: Data, orgId: String?) -> WebAccountInfo? {
|
|
self.parseAccountInfo(data, orgId: orgId)
|
|
}
|
|
#endif
|
|
|
|
private static func parseISO8601Date(_ string: String) -> Date? {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = formatter.date(from: string) {
|
|
return date
|
|
}
|
|
// Try without fractional seconds
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter.date(from: string)
|
|
}
|
|
|
|
private static func parseOrganizationResponse(
|
|
_ data: Data,
|
|
targetOrganizationID: String? = nil) throws -> OrganizationInfo
|
|
{
|
|
guard let organizations = try? JSONDecoder().decode([ClaudeWebOrganizationResponse].self, from: data) else {
|
|
throw FetchError.invalidResponse
|
|
}
|
|
if let targetOrganizationID = targetOrganizationID?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!targetOrganizationID.isEmpty
|
|
{
|
|
guard let selected = organizations.first(where: { $0.uuid == targetOrganizationID }) else {
|
|
throw FetchError.organizationNotFound(targetOrganizationID)
|
|
}
|
|
return self.organizationInfo(from: selected)
|
|
}
|
|
guard let selected = organizations.first(where: { $0.hasChatCapability })
|
|
?? organizations.first(where: { !$0.isApiOnly })
|
|
?? organizations.first
|
|
else {
|
|
throw FetchError.noOrganization
|
|
}
|
|
return self.organizationInfo(from: selected)
|
|
}
|
|
|
|
private static func organizationInfo(from selected: ClaudeWebOrganizationResponse) -> OrganizationInfo {
|
|
let name = selected.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let sanitized = (name?.isEmpty ?? true) ? nil : name
|
|
return OrganizationInfo(id: selected.uuid, name: sanitized)
|
|
}
|
|
|
|
public struct WebAccountInfo: Sendable {
|
|
public let email: String?
|
|
public let loginMethod: String?
|
|
|
|
public init(email: String?, loginMethod: String?) {
|
|
self.email = email
|
|
self.loginMethod = loginMethod
|
|
}
|
|
}
|
|
|
|
private struct AccountResponse: Decodable {
|
|
let emailAddress: String?
|
|
let memberships: [Membership]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case emailAddress = "email_address"
|
|
case memberships
|
|
}
|
|
|
|
struct Membership: Decodable {
|
|
let organization: Organization
|
|
|
|
struct Organization: Decodable {
|
|
let uuid: String?
|
|
let name: String?
|
|
let rateLimitTier: String?
|
|
let billingType: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case uuid
|
|
case name
|
|
case rateLimitTier = "rate_limit_tier"
|
|
case billingType = "billing_type"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func fetchAccountInfo(
|
|
sessionKey: String,
|
|
orgId: String?,
|
|
logger: ((String) -> Void)? = nil,
|
|
renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async -> WebAccountInfo?
|
|
{
|
|
let url = URL(string: "\(baseURL)/account")!
|
|
var request = URLRequest(url: url)
|
|
request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 15
|
|
|
|
do {
|
|
let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else { return nil }
|
|
renewalTracker?.observe(response: httpResponse)
|
|
logger?("Account API status: \(httpResponse.statusCode)")
|
|
guard httpResponse.statusCode == 200 else { return nil }
|
|
return Self.parseAccountInfo(data, orgId: orgId)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func parseAccountInfo(_ data: Data, orgId: String?) -> WebAccountInfo? {
|
|
guard let response = try? JSONDecoder().decode(AccountResponse.self, from: data) else { return nil }
|
|
let email = response.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let membership = Self.selectMembership(response.memberships, orgId: orgId)
|
|
let plan = ClaudePlan.webLoginMethod(
|
|
rateLimitTier: membership?.organization.rateLimitTier,
|
|
billingType: membership?.organization.billingType)
|
|
return WebAccountInfo(email: email, loginMethod: plan)
|
|
}
|
|
|
|
private static func selectMembership(
|
|
_ memberships: [AccountResponse.Membership]?,
|
|
orgId: String?) -> AccountResponse.Membership?
|
|
{
|
|
guard let memberships, !memberships.isEmpty else { return nil }
|
|
if let orgId {
|
|
if let match = memberships.first(where: { $0.organization.uuid == orgId }) { return match }
|
|
}
|
|
return memberships.first
|
|
}
|
|
|
|
private struct ProbeParseResult {
|
|
let keys: [String]
|
|
let emails: [String]
|
|
let planHints: [String]
|
|
let notableFields: [String]
|
|
let preview: String?
|
|
}
|
|
|
|
private static func parseProbeBody(
|
|
data: Data,
|
|
fallbackText: String,
|
|
contentType: String?) -> ProbeParseResult
|
|
{
|
|
let trimmed = fallbackText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let looksJSON = (contentType?.lowercased().contains("application/json") ?? false) ||
|
|
trimmed.hasPrefix("{") || trimmed.hasPrefix("[")
|
|
|
|
var keys: [String] = []
|
|
var notableFields: [String] = []
|
|
if looksJSON, let json = try? JSONSerialization.jsonObject(with: data) {
|
|
if let dict = json as? [String: Any] {
|
|
keys = dict.keys.sorted()
|
|
} else if let array = json as? [[String: Any]], let first = array.first {
|
|
keys = first.keys.sorted()
|
|
}
|
|
notableFields = Self.extractNotableFields(from: json)
|
|
}
|
|
|
|
let emails = Self.extractEmails(from: trimmed)
|
|
let planHints = Self.extractPlanHints(from: trimmed)
|
|
let preview = trimmed.isEmpty ? nil : String(trimmed.prefix(500))
|
|
return ProbeParseResult(
|
|
keys: keys,
|
|
emails: emails,
|
|
planHints: planHints,
|
|
notableFields: notableFields,
|
|
preview: preview)
|
|
}
|
|
|
|
private static func extractEmails(from text: String) -> [String] {
|
|
let pattern = #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
|
|
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
|
var results: [String] = []
|
|
regex.enumerateMatches(in: text, options: [], range: range) { match, _, _ in
|
|
guard let match, let r = Range(match.range(at: 0), in: text) else { return }
|
|
let value = String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty { results.append(value) }
|
|
}
|
|
return Array(Set(results)).sorted()
|
|
}
|
|
|
|
private static func extractPlanHints(from text: String) -> [String] {
|
|
let pattern = #"(?i)\b(max|pro|team|ultra|enterprise)\b"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
|
|
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
|
var results: [String] = []
|
|
regex.enumerateMatches(in: text, options: [], range: range) { match, _, _ in
|
|
guard let match, let r = Range(match.range(at: 1), in: text) else { return }
|
|
let value = String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty { results.append(value) }
|
|
}
|
|
return Array(Set(results)).sorted()
|
|
}
|
|
|
|
private static func extractNotableFields(from json: Any) -> [String] {
|
|
let pattern = #"(?i)(plan|tier|subscription|seat|billing|product)"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
|
|
var results: [String] = []
|
|
|
|
func keyMatches(_ key: String) -> Bool {
|
|
let range = NSRange(key.startIndex..<key.endIndex, in: key)
|
|
return regex.firstMatch(in: key, options: [], range: range) != nil
|
|
}
|
|
|
|
func appendValue(_ keyPath: String, value: Any) {
|
|
if results.count >= 40 { return }
|
|
let rendered: String
|
|
switch value {
|
|
case let str as String:
|
|
rendered = str
|
|
case let num as NSNumber:
|
|
rendered = num.stringValue
|
|
case let bool as Bool:
|
|
rendered = bool ? "true" : "false"
|
|
default:
|
|
return
|
|
}
|
|
let trimmed = rendered.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
results.append("\(keyPath)=\(trimmed)")
|
|
}
|
|
|
|
func walk(_ value: Any, path: String) {
|
|
if let dict = value as? [String: Any] {
|
|
for (key, nested) in dict {
|
|
let nextPath = path.isEmpty ? key : "\(path).\(key)"
|
|
if keyMatches(key) {
|
|
appendValue(nextPath, value: nested)
|
|
}
|
|
walk(nested, path: nextPath)
|
|
}
|
|
} else if let array = value as? [Any] {
|
|
for (idx, nested) in array.enumerated() {
|
|
let nextPath = "\(path)[\(idx)]"
|
|
walk(nested, path: nextPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(json, path: "")
|
|
return results
|
|
}
|
|
|
|
#else
|
|
|
|
public static func fetchUsage(logger: ((String) -> Void)? = nil) async throws -> WebUsageData {
|
|
throw FetchError.notSupportedOnThisPlatform
|
|
}
|
|
|
|
public static func fetchUsage(
|
|
browserDetection: BrowserDetection,
|
|
targetOrganizationID: String? = nil,
|
|
logger: ((String) -> Void)? = nil) async throws -> WebUsageData
|
|
{
|
|
_ = browserDetection
|
|
_ = targetOrganizationID
|
|
_ = logger
|
|
throw FetchError.notSupportedOnThisPlatform
|
|
}
|
|
|
|
public static func fetchUsage(
|
|
cookieHeader: String,
|
|
targetOrganizationID: String? = nil,
|
|
logger: ((String) -> Void)? = nil) async throws -> WebUsageData
|
|
{
|
|
_ = cookieHeader
|
|
_ = targetOrganizationID
|
|
_ = logger
|
|
throw FetchError.notSupportedOnThisPlatform
|
|
}
|
|
|
|
public static func fetchUsage(
|
|
using sessionKeyInfo: SessionKeyInfo,
|
|
targetOrganizationID: String? = nil,
|
|
logger: ((String) -> Void)? = nil) async throws -> WebUsageData
|
|
{
|
|
_ = targetOrganizationID
|
|
throw FetchError.notSupportedOnThisPlatform
|
|
}
|
|
|
|
public static func probeEndpoints(
|
|
_ endpoints: [String],
|
|
includePreview: Bool = false,
|
|
logger: ((String) -> Void)? = nil) async throws -> [ProbeResult]
|
|
{
|
|
throw FetchError.notSupportedOnThisPlatform
|
|
}
|
|
|
|
public static func hasSessionKey(browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> Bool {
|
|
_ = browserDetection
|
|
_ = logger
|
|
return false
|
|
}
|
|
|
|
public static func hasSessionKey(cookieHeader: String?) -> Bool {
|
|
guard let cookieHeader else { return false }
|
|
for pair in CookieHeaderNormalizer.pairs(from: cookieHeader) where pair.name == "sessionKey" {
|
|
let value = pair.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if value.hasPrefix("sk-ant-") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
public static func sessionKeyInfo(logger: ((String) -> Void)? = nil) throws -> SessionKeyInfo {
|
|
throw FetchError.notSupportedOnThisPlatform
|
|
}
|
|
|
|
#endif
|
|
}
|
|
|
|
extension ClaudeWebAPIFetcher {
|
|
private static func persistSessionKeyIfNeeded(
|
|
source: (initialSessionKey: String, label: String),
|
|
renewedCookieHeader: String?,
|
|
expectedCacheObservation: CookieHeaderCache.ConditionalMutationObservation,
|
|
persistInitialSessionKey: Bool,
|
|
logger: (String) -> Void)
|
|
{
|
|
let importedCookieHeader = persistInitialSessionKey ? "sessionKey=\(source.initialSessionKey)" : nil
|
|
guard let cookieHeader = renewedCookieHeader ?? importedCookieHeader else { return }
|
|
let stored = CookieHeaderCache.storeIfObservationCurrent(
|
|
provider: .claude,
|
|
expected: expectedCacheObservation,
|
|
cookieHeader: cookieHeader,
|
|
sourceLabel: source.label)
|
|
if renewedCookieHeader != nil {
|
|
logger(stored ? "Stored renewed Claude session key" : "Skipped renewal because the cache changed")
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class ClaudeWebSessionKeyRenewalTracker: @unchecked Sendable {
|
|
private let initialSessionKey: String
|
|
private let lock = NSLock()
|
|
private var currentSessionKey: String
|
|
|
|
init(initialSessionKey: String) {
|
|
self.initialSessionKey = initialSessionKey
|
|
self.currentSessionKey = initialSessionKey
|
|
}
|
|
|
|
var sessionKey: String {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
return self.currentSessionKey
|
|
}
|
|
|
|
var renewedCookieHeader: String? {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
guard self.currentSessionKey != self.initialSessionKey else { return nil }
|
|
return "sessionKey=\(self.currentSessionKey)"
|
|
}
|
|
|
|
func observe(response: HTTPURLResponse) {
|
|
guard response.statusCode == 200,
|
|
let sessionKey = Self.sessionKey(fromSetCookieHeaders: response.allHeaderFields)
|
|
else {
|
|
return
|
|
}
|
|
self.lock.lock()
|
|
self.currentSessionKey = sessionKey
|
|
self.lock.unlock()
|
|
}
|
|
|
|
private static func sessionKey(fromSetCookieHeaders fields: [AnyHashable: Any]) -> String? {
|
|
guard let value = fields.first(where: {
|
|
String(describing: $0.key).caseInsensitiveCompare("Set-Cookie") == .orderedSame
|
|
})?.value else {
|
|
return nil
|
|
}
|
|
var latestSessionKey: String?
|
|
for header in self.setCookieHeaderValues(from: value) {
|
|
latestSessionKey = self.sessionKey(fromSetCookieHeader: header) ?? latestSessionKey
|
|
}
|
|
return latestSessionKey
|
|
}
|
|
|
|
private static func setCookieHeaderValues(from value: Any) -> [String] {
|
|
if let values = value as? [String] {
|
|
return values
|
|
}
|
|
if let values = value as? [Any] {
|
|
return values.map { String(describing: $0) }
|
|
}
|
|
return [String(describing: value)]
|
|
}
|
|
|
|
private static func sessionKey(fromSetCookieHeader header: String) -> String? {
|
|
let pattern = #"(?i)(?:^|[,\r\n])\s*sessionKey=([^;,\r\n]+)"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
|
let range = NSRange(header.startIndex..<header.endIndex, in: header)
|
|
var latestSessionKey: String?
|
|
for match in regex.matches(in: header, range: range) {
|
|
guard match.numberOfRanges >= 2,
|
|
let valueRange = Range(match.range(at: 1), in: header)
|
|
else {
|
|
continue
|
|
}
|
|
let value = String(header[valueRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if value.hasPrefix("sk-ant-") {
|
|
latestSessionKey = value
|
|
}
|
|
}
|
|
return latestSessionKey
|
|
}
|
|
}
|
|
|
|
private enum ClaudeWebExtraUsageCost {
|
|
// MARK: - Extra usage cost (Claude "Extra")
|
|
|
|
static func parse(from value: Any?) -> ProviderCostSnapshot? {
|
|
guard let extraUsage = value as? [String: Any] else { return nil }
|
|
guard let used = Self.doubleValue(extraUsage["used_credits"]),
|
|
let limit = Self.doubleValue(extraUsage["monthly_limit"] ?? extraUsage["monthly_credit_limit"]),
|
|
limit > 0 else { return nil }
|
|
let currency = (extraUsage["currency"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let currencyCode = currency?.isEmpty == false ? currency ?? "USD" : "USD"
|
|
return Self.makeExtraUsageCost(
|
|
usedCredits: used,
|
|
monthlyCreditLimit: limit,
|
|
currencyCode: currencyCode)
|
|
}
|
|
|
|
struct OverageSpendLimitResponse: Decodable {
|
|
let monthlyCreditLimit: Double?
|
|
let currency: String?
|
|
let usedCredits: Double?
|
|
let isEnabled: Bool?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case monthlyCreditLimit = "monthly_credit_limit"
|
|
case currency
|
|
case usedCredits = "used_credits"
|
|
case isEnabled = "is_enabled"
|
|
}
|
|
}
|
|
|
|
/// Best-effort fetch of Claude Extra spend/limit (does not fail the main usage fetch).
|
|
static func fetch(
|
|
baseURL: String,
|
|
orgId: String,
|
|
sessionKey: String,
|
|
logger: ((String) -> Void)? = nil,
|
|
renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async -> ProviderCostSnapshot?
|
|
{
|
|
let url = URL(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit")!
|
|
var request = URLRequest(url: url)
|
|
request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 15
|
|
|
|
do {
|
|
let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else { return nil }
|
|
renewalTracker?.observe(response: httpResponse)
|
|
logger?("Overage API status: \(httpResponse.statusCode)")
|
|
guard httpResponse.statusCode == 200 else { return nil }
|
|
return Self.parseOverageSpendLimit(data)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func parseOverageSpendLimit(_ data: Data) -> ProviderCostSnapshot? {
|
|
guard let decoded = try? JSONDecoder().decode(OverageSpendLimitResponse.self, from: data) else { return nil }
|
|
guard decoded.isEnabled == true else { return nil }
|
|
guard let used = decoded.usedCredits,
|
|
let limit = decoded.monthlyCreditLimit,
|
|
let currency = decoded.currency,
|
|
!currency.isEmpty else { return nil }
|
|
|
|
return Self.makeExtraUsageCost(
|
|
usedCredits: used,
|
|
monthlyCreditLimit: limit,
|
|
currencyCode: currency)
|
|
}
|
|
|
|
static func makeExtraUsageCost(
|
|
usedCredits: Double,
|
|
monthlyCreditLimit: Double,
|
|
currencyCode: String) -> ProviderCostSnapshot
|
|
{
|
|
let usedAmount = usedCredits / 100.0
|
|
let limitAmount = monthlyCreditLimit / 100.0
|
|
|
|
return ProviderCostSnapshot(
|
|
used: usedAmount,
|
|
limit: limitAmount,
|
|
currencyCode: currencyCode,
|
|
period: "Monthly cap",
|
|
resetsAt: nil,
|
|
updatedAt: Date())
|
|
}
|
|
|
|
static func doubleValue(_ value: Any?) -> Double? {
|
|
switch value {
|
|
case let int as Int:
|
|
Double(int)
|
|
case let double as Double:
|
|
double
|
|
case let string as String:
|
|
Double(string)
|
|
default:
|
|
nil
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
extension ClaudeWebAPIFetcher {
|
|
fileprivate static func fetchUsageSerialized(
|
|
browserDetection: BrowserDetection,
|
|
targetOrganizationID: String?,
|
|
logger: ((String) -> Void)?) async throws -> WebUsageData
|
|
{
|
|
let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") }
|
|
var cacheObservation = CookieHeaderCache.observeForConditionalMutation(provider: .claude)
|
|
|
|
if let cached = cacheObservation.entry,
|
|
!cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
log("Using cached cookie header from \(cached.sourceLabel)")
|
|
do {
|
|
return try await self.fetchUsageAndRenewCache(
|
|
cachedEntry: cached,
|
|
targetOrganizationID: targetOrganizationID,
|
|
logger: log)
|
|
} catch let error as FetchError {
|
|
switch error {
|
|
case .unauthorized, .noSessionKeyFound, .invalidSessionKey:
|
|
let cleared = CookieHeaderCache.clearIfCurrent(provider: .claude, expected: cached)
|
|
cacheObservation = .authoritative(cleared ? nil : cached)
|
|
default:
|
|
throw error
|
|
}
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
let sessionInfo = try extractSessionKeyInfo(browserDetection: browserDetection, logger: log)
|
|
log("Found session key (\(sessionInfo.cookieCount) cookies)")
|
|
|
|
return try await self.fetchUsage(
|
|
using: sessionInfo,
|
|
targetOrganizationID: targetOrganizationID,
|
|
logger: log,
|
|
cacheSourceLabel: sessionInfo.sourceLabel,
|
|
expectedCacheObservation: cacheObservation,
|
|
persistInitialSessionKey: true)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private struct ClaudeWebOrganizationResponse: Decodable {
|
|
let uuid: String
|
|
let name: String?
|
|
let capabilities: [String]?
|
|
|
|
var normalizedCapabilities: Set<String> {
|
|
Set((self.capabilities ?? []).map { $0.lowercased() })
|
|
}
|
|
|
|
var hasChatCapability: Bool {
|
|
self.normalizedCapabilities.contains("chat")
|
|
}
|
|
|
|
var isApiOnly: Bool {
|
|
let normalized = self.normalizedCapabilities
|
|
return !normalized.isEmpty && normalized == ["api"]
|
|
}
|
|
}
|