mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
The Codex plan tab reused the shared connect, no-credentials and reconnect views whose copy was hardcoded for Claude, so the Codex tab showed Connect Claude subscription. Worse, the no-credentials Try Again button always re-ran the Claude bootstrap. The three views now take title, message and action from the call site. The oauth usage endpoint gained a limits array carrying model-scoped weekly buckets (currently Fable). Parse weekly_scoped entries generically by their display name and show them in the Claude hover popover and the plan tab, with the usual pace projection. Parser now has test coverage using the captured live response shape.
285 lines
11 KiB
Swift
285 lines
11 KiB
Swift
import Foundation
|
|
|
|
/// Orchestrates "given a credential record, fetch live quota from Anthropic
|
|
/// and surface a result the UI can render". All token persistence lives in
|
|
/// `ClaudeCredentialStore`; the only state this service holds is the
|
|
/// 429 backoff window for the usage endpoint.
|
|
enum ClaudeSubscriptionService {
|
|
private static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
|
|
private static let betaHeader = "oauth-2025-04-20"
|
|
private static let userAgent = "claude-code/2.1.0"
|
|
private static let usageBlockedUntilKey = "codeburn.claude.usage.blockedUntil"
|
|
|
|
enum FetchError: Error, LocalizedError {
|
|
case notBootstrapped
|
|
case bootstrapFailed(ClaudeCredentialStore.StoreError)
|
|
case rateLimited(retryAt: Date)
|
|
case usageHTTPError(Int, String?)
|
|
case usageDecodeFailed
|
|
case network(Error)
|
|
case credential(ClaudeCredentialStore.StoreError)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notBootstrapped:
|
|
return "Connect Claude in the Plan tab to start tracking quota."
|
|
case let .bootstrapFailed(err):
|
|
return err.errorDescription
|
|
case let .rateLimited(retryAt):
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .short
|
|
return "Anthropic rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
|
|
case let .usageHTTPError(code, body):
|
|
return "Quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
|
|
case .usageDecodeFailed:
|
|
return "Quota response was malformed."
|
|
case let .network(err):
|
|
return "Network error: \(err.localizedDescription)"
|
|
case let .credential(err):
|
|
return err.errorDescription
|
|
}
|
|
}
|
|
|
|
/// True when the user must take action (re-run claude/login or click
|
|
/// Reconnect). Drives the red "Reconnect" UI path.
|
|
var isTerminal: Bool {
|
|
if case let .credential(err) = self { return err.isTerminal }
|
|
if case let .bootstrapFailed(err) = self { return err.isTerminal }
|
|
return false
|
|
}
|
|
|
|
var rateLimitRetryAt: Date? {
|
|
if case let .rateLimited(retryAt) = self { return retryAt }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// User-initiated. Reads Claude's keychain (PROMPTS), copies to our keychain,
|
|
/// then fetches usage. Idempotent — safe to call again to "reconnect".
|
|
static func bootstrap() async throws -> SubscriptionUsage {
|
|
// Honour the same 429 backoff that refreshIfBootstrapped respects.
|
|
// Without this, a user spamming Reconnect during a sustained
|
|
// rate-limit window hammers Anthropic on every click — exactly the
|
|
// pattern that escalates the backoff.
|
|
if let until = usageBlockedUntil(), until > Date() {
|
|
throw FetchError.rateLimited(retryAt: until)
|
|
}
|
|
let record: ClaudeCredentialStore.CredentialRecord
|
|
do {
|
|
record = try ClaudeCredentialStore.bootstrap()
|
|
} catch let err as ClaudeCredentialStore.StoreError {
|
|
throw FetchError.bootstrapFailed(err)
|
|
}
|
|
return try await fetchWithRecord(initial: record)
|
|
}
|
|
|
|
/// Background refresh. Never prompts. Returns nil if not yet bootstrapped.
|
|
static func refreshIfBootstrapped() async throws -> SubscriptionUsage? {
|
|
guard ClaudeCredentialStore.isBootstrapCompleted else {
|
|
return nil
|
|
}
|
|
|
|
// Honour an outstanding rate-limit window — we recorded a 429 recently
|
|
// and Anthropic told us when to come back.
|
|
if let until = usageBlockedUntil(), until > Date() {
|
|
throw FetchError.rateLimited(retryAt: until)
|
|
}
|
|
|
|
do {
|
|
let token = try await ClaudeCredentialStore.freshAccessToken()
|
|
guard let token else { throw FetchError.notBootstrapped }
|
|
return try await fetch(token: token, allowOne401Recovery: true)
|
|
} catch let err as ClaudeCredentialStore.StoreError {
|
|
throw FetchError.credential(err)
|
|
} catch let err as FetchError {
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/// Reset everything — used on user-initiated disconnect.
|
|
static func disconnect() {
|
|
ClaudeCredentialStore.resetBootstrap()
|
|
clearUsageBlock()
|
|
}
|
|
|
|
// MARK: - Internal
|
|
|
|
private static func fetchWithRecord(initial record: ClaudeCredentialStore.CredentialRecord) async throws -> SubscriptionUsage {
|
|
do {
|
|
return try await fetch(token: record.accessToken, allowOne401Recovery: true)
|
|
} catch let err as FetchError {
|
|
throw err
|
|
} catch let err as ClaudeCredentialStore.StoreError {
|
|
throw FetchError.credential(err)
|
|
}
|
|
}
|
|
|
|
private static func fetch(token: String, allowOne401Recovery: Bool) async throws -> SubscriptionUsage {
|
|
var request = URLRequest(url: usageURL)
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 30
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta")
|
|
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
|
|
|
let data: Data
|
|
let response: URLResponse
|
|
do {
|
|
(data, response) = try await URLSession.shared.data(for: request)
|
|
} catch {
|
|
throw FetchError.network(error)
|
|
}
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw FetchError.usageHTTPError(-1, nil)
|
|
}
|
|
|
|
switch http.statusCode {
|
|
case 200:
|
|
clearUsageBlock()
|
|
do {
|
|
let tier = try ClaudeCredentialStore.subscriptionTier()
|
|
return try parseUsage(data, rawTier: tier)
|
|
} catch {
|
|
throw FetchError.usageDecodeFailed
|
|
}
|
|
case 401:
|
|
if allowOne401Recovery {
|
|
let newToken = try await ClaudeCredentialStore.refreshAfter401()
|
|
return try await fetch(token: newToken, allowOne401Recovery: false)
|
|
}
|
|
throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8))
|
|
case 429:
|
|
let body = String(data: data, encoding: .utf8)
|
|
let retryAfter = parseRetryAfter(body: body)
|
|
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
|
|
throw FetchError.rateLimited(retryAt: until)
|
|
default:
|
|
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
|
|
}
|
|
}
|
|
|
|
// MARK: - 429 backoff
|
|
|
|
private static func usageBlockedUntil() -> Date? {
|
|
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
|
|
}
|
|
|
|
private static func clearUsageBlock() {
|
|
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
|
|
}
|
|
|
|
@discardableResult
|
|
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
|
|
let seconds = max(retryAfterSeconds ?? 300, 60)
|
|
let until = Date().addingTimeInterval(TimeInterval(seconds))
|
|
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
|
|
return until
|
|
}
|
|
|
|
private static func parseRetryAfter(body: String?) -> Int? {
|
|
guard let body, let data = body.data(using: .utf8) else { return nil }
|
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
if let n = json["retry_after"] as? Int { return n }
|
|
if let s = json["retry_after"] as? String, let n = Int(s) { return n }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Response mapping
|
|
|
|
/// Decodes a usage endpoint response body. Internal so tests can feed the
|
|
/// captured JSON shape without a network round trip.
|
|
static func parseUsage(_ data: Data, rawTier: String?) throws -> SubscriptionUsage {
|
|
let decoded = try JSONDecoder().decode(UsageResponse.self, from: data)
|
|
return mapResponse(decoded, rawTier: rawTier)
|
|
}
|
|
|
|
private struct UsageResponse: Decodable {
|
|
let fiveHour: Window?
|
|
let sevenDay: Window?
|
|
let sevenDayOpus: Window?
|
|
let sevenDaySonnet: Window?
|
|
let limits: [Limit]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case fiveHour = "five_hour"
|
|
case sevenDay = "seven_day"
|
|
case sevenDayOpus = "seven_day_opus"
|
|
case sevenDaySonnet = "seven_day_sonnet"
|
|
case limits
|
|
}
|
|
}
|
|
|
|
private struct Window: Decodable {
|
|
let utilization: Double?
|
|
let resetsAt: String?
|
|
enum CodingKeys: String, CodingKey {
|
|
case utilization
|
|
case resetsAt = "resets_at"
|
|
}
|
|
}
|
|
|
|
/// Entry in the `limits` array. Model-scoped weekly buckets (like Fable)
|
|
/// only appear here, not as named top-level windows.
|
|
private struct Limit: Decodable {
|
|
let kind: String?
|
|
let percent: Double?
|
|
let resetsAt: String?
|
|
let scope: Scope?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case kind, percent, scope
|
|
case resetsAt = "resets_at"
|
|
}
|
|
|
|
struct Scope: Decodable {
|
|
let model: Model?
|
|
struct Model: Decodable {
|
|
let displayName: String?
|
|
enum CodingKeys: String, CodingKey {
|
|
case displayName = "display_name"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
|
|
let scopedWeekly = (r.limits ?? []).compactMap { limit -> SubscriptionUsage.ScopedWindow? in
|
|
guard limit.kind == "weekly_scoped",
|
|
let name = limit.scope?.model?.displayName,
|
|
let percent = limit.percent
|
|
else { return nil }
|
|
return SubscriptionUsage.ScopedWindow(
|
|
label: name,
|
|
percent: percent,
|
|
resetsAt: parseDate(limit.resetsAt)
|
|
)
|
|
}
|
|
return SubscriptionUsage(
|
|
tier: SubscriptionUsage.tier(from: rawTier),
|
|
rawTier: rawTier,
|
|
fiveHourPercent: r.fiveHour?.utilization,
|
|
fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt),
|
|
sevenDayPercent: r.sevenDay?.utilization,
|
|
sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt),
|
|
sevenDayOpusPercent: r.sevenDayOpus?.utilization,
|
|
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
|
|
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
|
|
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
|
|
scopedWeekly: scopedWeekly,
|
|
fetchedAt: Date()
|
|
)
|
|
}
|
|
|
|
private static func parseDate(_ s: String?) -> Date? {
|
|
guard let s, !s.isEmpty else { return nil }
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let d = f.date(from: s) { return d }
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f.date(from: s)
|
|
}
|
|
}
|