chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import SweetCookieKit
|
||||
|
||||
private let opencodeCookieImportOrder: BrowserCookieImportOrder =
|
||||
ProviderDefaults.metadata[.opencode]?.browserCookieOrder ?? Browser.defaultImportOrder
|
||||
|
||||
public enum OpenCodeCookieImporter {
|
||||
private static let cookieClient = BrowserCookieClient()
|
||||
private static let cookieDomains = ["opencode.ai", "app.opencode.ai"]
|
||||
|
||||
public struct SessionInfo: Sendable {
|
||||
public let cookies: [HTTPCookie]
|
||||
public let sourceLabel: String
|
||||
|
||||
public init(cookies: [HTTPCookie], sourceLabel: String) {
|
||||
self.cookies = cookies
|
||||
self.sourceLabel = sourceLabel
|
||||
}
|
||||
|
||||
public var cookieHeader: String {
|
||||
self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
|
||||
public static func importSession(
|
||||
browserDetection: BrowserDetection,
|
||||
preferredBrowsers: [Browser] = [.chrome],
|
||||
logger: ((String) -> Void)? = nil) throws -> SessionInfo
|
||||
{
|
||||
let log: (String) -> Void = { msg in logger?("[opencode-cookie] \(msg)") }
|
||||
let installedBrowsers = preferredBrowsers.isEmpty
|
||||
? opencodeCookieImportOrder.cookieImportCandidates(using: browserDetection)
|
||||
: preferredBrowsers.cookieImportCandidates(using: browserDetection)
|
||||
|
||||
for browserSource in installedBrowsers {
|
||||
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 httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
|
||||
if !httpCookies.isEmpty {
|
||||
let hasAuthCookie = httpCookies.contains { cookie in
|
||||
cookie.name == "auth" || cookie.name == "__Host-auth"
|
||||
}
|
||||
if !hasAuthCookie {
|
||||
log("Skipping \(source.label) cookies: missing auth cookie")
|
||||
continue
|
||||
}
|
||||
log("Found \(httpCookies.count) OpenCode cookies in \(source.label)")
|
||||
return SessionInfo(cookies: httpCookies, sourceLabel: source.label)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
BrowserCookieAccessGate.recordIfNeeded(error)
|
||||
log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
throw OpenCodeCookieImportError.noCookies
|
||||
}
|
||||
|
||||
public static func hasSession(
|
||||
browserDetection: BrowserDetection,
|
||||
preferredBrowsers: [Browser] = [.chrome],
|
||||
logger: ((String) -> Void)? = nil) -> Bool
|
||||
{
|
||||
do {
|
||||
_ = try self.importSession(
|
||||
browserDetection: browserDetection,
|
||||
preferredBrowsers: preferredBrowsers,
|
||||
logger: logger)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenCodeCookieImportError: LocalizedError {
|
||||
case noCookies
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCookies:
|
||||
"No OpenCode session cookies found in browsers."
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenCodeProviderDescriptor {
|
||||
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
|
||||
|
||||
static func makeDescriptor() -> ProviderDescriptor {
|
||||
ProviderDescriptor(
|
||||
id: .opencode,
|
||||
metadata: ProviderMetadata(
|
||||
id: .opencode,
|
||||
displayName: "OpenCode",
|
||||
sessionLabel: "5-hour",
|
||||
weeklyLabel: "Weekly",
|
||||
opusLabel: nil,
|
||||
supportsOpus: false,
|
||||
supportsCredits: false,
|
||||
creditsHint: "",
|
||||
toggleTitle: "Show OpenCode usage",
|
||||
cliName: "opencode",
|
||||
defaultEnabled: false,
|
||||
isPrimaryProvider: false,
|
||||
usesAccountFallback: false,
|
||||
browserCookieOrder: ProviderBrowserCookieDefaults.opencodeCookieImportOrder,
|
||||
dashboardURL: "https://opencode.ai",
|
||||
statusPageURL: nil),
|
||||
branding: ProviderBranding(
|
||||
iconStyle: .opencode,
|
||||
iconResourceName: "ProviderIcon-opencode",
|
||||
color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255)),
|
||||
tokenCost: ProviderTokenCostConfig(
|
||||
supportsTokenCost: false,
|
||||
noDataMessage: { "OpenCode cost summary is not supported." }),
|
||||
fetchPlan: ProviderFetchPlan(
|
||||
sourceModes: [.auto, .web],
|
||||
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenCodeUsageFetchStrategy()] })),
|
||||
cli: ProviderCLIConfig(
|
||||
name: "opencode",
|
||||
versionDetector: nil))
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenCodeUsageFetchStrategy: ProviderFetchStrategy {
|
||||
let id: String = "opencode.web"
|
||||
let kind: ProviderFetchKind = .web
|
||||
|
||||
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
|
||||
guard context.settings?.opencode?.cookieSource != .off else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
|
||||
let workspaceOverride = context.settings?.opencode?.workspaceID
|
||||
?? context.env["CODEXBAR_OPENCODE_WORKSPACE_ID"]
|
||||
let cookieSource = context.settings?.opencode?.cookieSource ?? .auto
|
||||
do {
|
||||
let cookieHeader = try Self.resolveCookieHeader(context: context, allowCached: true)
|
||||
let snapshot = try await OpenCodeUsageFetcher.fetchUsage(
|
||||
cookieHeader: cookieHeader,
|
||||
timeout: context.webTimeout,
|
||||
workspaceIDOverride: workspaceOverride)
|
||||
return self.makeResult(
|
||||
usage: snapshot.toUsageSnapshot(),
|
||||
sourceLabel: "web")
|
||||
} catch OpenCodeUsageError.invalidCredentials where cookieSource != .manual {
|
||||
#if os(macOS)
|
||||
CookieHeaderCache.clear(provider: .opencode)
|
||||
let cookieHeader = try Self.resolveCookieHeader(context: context, allowCached: false)
|
||||
let snapshot = try await OpenCodeUsageFetcher.fetchUsage(
|
||||
cookieHeader: cookieHeader,
|
||||
timeout: context.webTimeout,
|
||||
workspaceIDOverride: workspaceOverride)
|
||||
return self.makeResult(
|
||||
usage: snapshot.toUsageSnapshot(),
|
||||
sourceLabel: "web")
|
||||
#else
|
||||
throw OpenCodeUsageError.invalidCredentials
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
private static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String {
|
||||
try OpenCodeWebCookieSupport.resolveCookieHeader(
|
||||
context: OpenCodeWebCookieSupport.Context(
|
||||
settings: context.settings?.opencode,
|
||||
provider: .opencode,
|
||||
browserDetection: context.browserDetection,
|
||||
allowCached: allowCached),
|
||||
invalidCookie: OpenCodeSettingsError.invalidCookie,
|
||||
missingCookie: OpenCodeSettingsError.missingCookie)
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenCodeSettingsError: LocalizedError {
|
||||
case missingCookie
|
||||
case invalidCookie
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingCookie:
|
||||
"No OpenCode session cookies found in browsers."
|
||||
case .invalidCookie:
|
||||
"OpenCode cookie header is invalid."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public enum OpenCodeUsageError: LocalizedError {
|
||||
case invalidCredentials
|
||||
case networkError(String)
|
||||
case apiError(String)
|
||||
case parseFailed(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidCredentials:
|
||||
"OpenCode session cookie is invalid or expired."
|
||||
case let .networkError(message):
|
||||
"OpenCode network error: \(message)"
|
||||
case let .apiError(message):
|
||||
"OpenCode API error: \(message)"
|
||||
case let .parseFailed(message):
|
||||
"OpenCode parse error: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenCodeUsageFetcher: Sendable {
|
||||
private static let log = CodexBarLog.logger(LogCategories.opencodeUsage)
|
||||
private static let baseURL = URL(string: "https://opencode.ai")!
|
||||
private static let serverURL = URL(string: "https://opencode.ai/_server")!
|
||||
private static let workspacesServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f"
|
||||
private static let subscriptionServerID = "7abeebee372f304e050aaaf92be863f4a86490e382f8c79db68fd94040d691b4"
|
||||
private static let percentKeys = [
|
||||
"usagePercent",
|
||||
"usedPercent",
|
||||
"percentUsed",
|
||||
"percent",
|
||||
"usage_percent",
|
||||
"used_percent",
|
||||
"utilization",
|
||||
"utilizationPercent",
|
||||
"utilization_percent",
|
||||
"usage",
|
||||
]
|
||||
private static let resetInKeys = [
|
||||
"resetInSec",
|
||||
"resetInSeconds",
|
||||
"resetSeconds",
|
||||
"reset_sec",
|
||||
"reset_in_sec",
|
||||
"resetsInSec",
|
||||
"resetsInSeconds",
|
||||
"resetIn",
|
||||
"resetSec",
|
||||
]
|
||||
private static let resetAtKeys = [
|
||||
"resetAt",
|
||||
"resetsAt",
|
||||
"reset_at",
|
||||
"resets_at",
|
||||
"nextReset",
|
||||
"next_reset",
|
||||
"renewAt",
|
||||
"renew_at",
|
||||
]
|
||||
private static let renewAtKeys = [
|
||||
"renewAt",
|
||||
"renew_at",
|
||||
]
|
||||
private static func makeISO8601Formatter() -> ISO8601DateFormatter {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
private struct ServerRequest {
|
||||
let serverID: String
|
||||
let args: [Any]?
|
||||
let method: String
|
||||
let referer: URL
|
||||
}
|
||||
|
||||
public static func fetchUsage(
|
||||
cookieHeader: String,
|
||||
timeout: TimeInterval,
|
||||
now: Date = Date(),
|
||||
workspaceIDOverride: String? = nil,
|
||||
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> OpenCodeUsageSnapshot
|
||||
{
|
||||
guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else {
|
||||
throw OpenCodeUsageError.invalidCredentials
|
||||
}
|
||||
let workspaceID: String = if let override = self.normalizeWorkspaceID(workspaceIDOverride) {
|
||||
override
|
||||
} else {
|
||||
try await self.fetchWorkspaceID(
|
||||
cookieHeader: requestCookieHeader,
|
||||
timeout: timeout,
|
||||
transport: transport)
|
||||
}
|
||||
let subscriptionText = try await self.fetchSubscriptionInfo(
|
||||
workspaceID: workspaceID,
|
||||
cookieHeader: requestCookieHeader,
|
||||
timeout: timeout,
|
||||
transport: transport)
|
||||
return try self.parseSubscription(text: subscriptionText, now: now)
|
||||
}
|
||||
|
||||
private static func fetchWorkspaceID(
|
||||
cookieHeader: String,
|
||||
timeout: TimeInterval,
|
||||
transport: any ProviderHTTPTransport) async throws -> String
|
||||
{
|
||||
let text = try await self.fetchServerText(
|
||||
request: ServerRequest(
|
||||
serverID: self.workspacesServerID,
|
||||
args: nil,
|
||||
method: "GET",
|
||||
referer: self.baseURL),
|
||||
cookieHeader: cookieHeader,
|
||||
timeout: timeout,
|
||||
transport: transport)
|
||||
if self.looksSignedOut(text: text) {
|
||||
throw OpenCodeUsageError.invalidCredentials
|
||||
}
|
||||
var ids = self.parseWorkspaceIDs(text: text)
|
||||
if ids.isEmpty {
|
||||
ids = self.parseWorkspaceIDsFromJSON(text: text)
|
||||
}
|
||||
if ids.isEmpty {
|
||||
Self.log.error("OpenCode workspace ids missing after GET; retrying with POST.")
|
||||
let fallback = try await self.fetchServerText(
|
||||
request: ServerRequest(
|
||||
serverID: self.workspacesServerID,
|
||||
args: [],
|
||||
method: "POST",
|
||||
referer: self.baseURL),
|
||||
cookieHeader: cookieHeader,
|
||||
timeout: timeout,
|
||||
transport: transport)
|
||||
if self.looksSignedOut(text: fallback) {
|
||||
throw OpenCodeUsageError.invalidCredentials
|
||||
}
|
||||
ids = self.parseWorkspaceIDs(text: fallback)
|
||||
if ids.isEmpty {
|
||||
ids = self.parseWorkspaceIDsFromJSON(text: fallback)
|
||||
}
|
||||
if ids.isEmpty {
|
||||
self.logParseSummary(text: fallback)
|
||||
throw OpenCodeUsageError.parseFailed("Missing workspace id.")
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
|
||||
private static func fetchSubscriptionInfo(
|
||||
workspaceID: String,
|
||||
cookieHeader: String,
|
||||
timeout: TimeInterval,
|
||||
transport: any ProviderHTTPTransport) async throws -> String
|
||||
{
|
||||
let referer = URL(string: "https://opencode.ai/workspace/\(workspaceID)/billing") ?? self.baseURL
|
||||
let text = try await self.fetchServerText(
|
||||
request: ServerRequest(
|
||||
serverID: self.subscriptionServerID,
|
||||
args: [workspaceID],
|
||||
method: "GET",
|
||||
referer: referer),
|
||||
cookieHeader: cookieHeader,
|
||||
timeout: timeout,
|
||||
transport: transport)
|
||||
if self.looksSignedOut(text: text) {
|
||||
throw OpenCodeUsageError.invalidCredentials
|
||||
}
|
||||
if self.isExplicitNullPayload(text: text) {
|
||||
Self.log.warning("OpenCode subscription GET returned null; skipping POST fallback.")
|
||||
throw self.missingSubscriptionDataError(workspaceID: workspaceID)
|
||||
}
|
||||
if self.parseSubscriptionJSON(text: text, now: Date()) == nil,
|
||||
self.extractDouble(
|
||||
pattern: #"rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#,
|
||||
text: text) == nil
|
||||
{
|
||||
Self.log.error("OpenCode subscription payload missing after GET; retrying with POST.")
|
||||
let fallback = try await self.fetchServerText(
|
||||
request: ServerRequest(
|
||||
serverID: self.subscriptionServerID,
|
||||
args: [workspaceID],
|
||||
method: "POST",
|
||||
referer: referer),
|
||||
cookieHeader: cookieHeader,
|
||||
timeout: timeout,
|
||||
transport: transport)
|
||||
if self.looksSignedOut(text: fallback) {
|
||||
throw OpenCodeUsageError.invalidCredentials
|
||||
}
|
||||
if self.isExplicitNullPayload(text: fallback) {
|
||||
Self.log.warning("OpenCode subscription POST returned null.")
|
||||
throw self.missingSubscriptionDataError(workspaceID: workspaceID)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static func isExplicitNullPayload(text: String) -> Bool {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.caseInsensitiveCompare("null") == .orderedSame {
|
||||
return true
|
||||
}
|
||||
guard let data = trimmed.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data, options: [])
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return object is NSNull
|
||||
}
|
||||
|
||||
private static func missingSubscriptionDataError(workspaceID: String) -> OpenCodeUsageError {
|
||||
OpenCodeUsageError.apiError(
|
||||
"No subscription usage data was returned for workspace \(workspaceID). " +
|
||||
"This usually means this workspace does not have OpenCode subscription quota data available.")
|
||||
}
|
||||
|
||||
private static func normalizeWorkspaceID(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("wrk_"), trimmed.count > 4 {
|
||||
return trimmed
|
||||
}
|
||||
if let url = URL(string: trimmed) {
|
||||
let parts = url.pathComponents
|
||||
if let index = parts.firstIndex(of: "workspace"),
|
||||
parts.count > index + 1
|
||||
{
|
||||
let candidate = parts[index + 1]
|
||||
if candidate.hasPrefix("wrk_"), candidate.count > 4 {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
if let match = trimmed.range(of: #"wrk_[A-Za-z0-9]+"#, options: .regularExpression) {
|
||||
return String(trimmed[match])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func fetchServerText(
|
||||
request serverRequest: ServerRequest,
|
||||
cookieHeader: String,
|
||||
timeout: TimeInterval,
|
||||
transport: any ProviderHTTPTransport) async throws -> String
|
||||
{
|
||||
let url = self.serverRequestURL(
|
||||
serverID: serverRequest.serverID,
|
||||
args: serverRequest.args,
|
||||
method: serverRequest.method)
|
||||
var urlRequest = URLRequest(url: url)
|
||||
urlRequest.httpMethod = serverRequest.method
|
||||
urlRequest.timeoutInterval = timeout
|
||||
urlRequest.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
urlRequest.setValue(serverRequest.serverID, forHTTPHeaderField: "X-Server-Id")
|
||||
urlRequest.setValue("server-fn:\(UUID().uuidString)", forHTTPHeaderField: "X-Server-Instance")
|
||||
urlRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
urlRequest.setValue(self.baseURL.absoluteString, forHTTPHeaderField: "Origin")
|
||||
urlRequest.setValue(serverRequest.referer.absoluteString, forHTTPHeaderField: "Referer")
|
||||
urlRequest.setValue("text/javascript, application/json;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
|
||||
if serverRequest.method.uppercased() != "GET",
|
||||
let args = serverRequest.args
|
||||
{
|
||||
let body = try JSONSerialization.data(withJSONObject: args, options: [])
|
||||
urlRequest.httpBody = body
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let response: ProviderHTTPResponse
|
||||
do {
|
||||
response = try await transport.response(for: urlRequest)
|
||||
} catch let error as URLError where error.code == .badServerResponse {
|
||||
throw OpenCodeUsageError.networkError("Invalid response")
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
|
||||
guard response.statusCode == 200 else {
|
||||
let bodyText = String(data: response.data, encoding: .utf8) ?? ""
|
||||
let contentType = response.response.value(forHTTPHeaderField: "Content-Type") ?? "unknown"
|
||||
Self.log
|
||||
.error("OpenCode returned \(response.statusCode) (type=\(contentType) length=\(response.data.count))")
|
||||
if self.looksSignedOut(text: bodyText) {
|
||||
throw OpenCodeUsageError.invalidCredentials
|
||||
}
|
||||
if response.statusCode == 401 || response.statusCode == 403 {
|
||||
throw OpenCodeUsageError.invalidCredentials
|
||||
}
|
||||
if let message = self.extractServerErrorMessage(from: bodyText) {
|
||||
throw OpenCodeUsageError.apiError("HTTP \(response.statusCode): \(message)")
|
||||
}
|
||||
throw OpenCodeUsageError.apiError("HTTP \(response.statusCode)")
|
||||
}
|
||||
|
||||
guard let text = String(data: response.data, encoding: .utf8) else {
|
||||
throw OpenCodeUsageError.parseFailed("Response was not UTF-8.")
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
static func parseSubscription(text: String, now: Date) throws -> OpenCodeUsageSnapshot {
|
||||
if let snapshot = self.parseSubscriptionJSON(text: text, now: now) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
guard let rollingPercent = self.extractDouble(
|
||||
pattern: #"rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#,
|
||||
text: text),
|
||||
let rollingReset = self.extractInt(
|
||||
pattern: #"rollingUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#,
|
||||
text: text),
|
||||
let weeklyPercent = self.extractDouble(
|
||||
pattern: #"weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#,
|
||||
text: text),
|
||||
let weeklyReset = self.extractInt(
|
||||
pattern: #"weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#,
|
||||
text: text)
|
||||
else {
|
||||
self.logParseSummary(text: text)
|
||||
throw OpenCodeUsageError.parseFailed("Missing usage fields.")
|
||||
}
|
||||
|
||||
return OpenCodeUsageSnapshot(
|
||||
rollingUsagePercent: rollingPercent,
|
||||
weeklyUsagePercent: weeklyPercent,
|
||||
rollingResetInSec: rollingReset,
|
||||
weeklyResetInSec: weeklyReset,
|
||||
updatedAt: now)
|
||||
}
|
||||
|
||||
private static func parseSubscriptionJSON(text: String, now: Date) -> OpenCodeUsageSnapshot? {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data, options: [])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let snapshot = self.parseUsageJSON(object: object, now: now) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
if let snapshot = self.parseUsageFromCandidates(object: object, now: now) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
self.logParseSummary(object: object)
|
||||
return nil
|
||||
}
|
||||
|
||||
static func parseWorkspaceIDs(text: String) -> [String] {
|
||||
let pattern = #"id\s*:\s*\"(wrk_[^\"]+)\""#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
|
||||
let nsrange = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
return regex.matches(in: text, options: [], range: nsrange).compactMap { match in
|
||||
guard let range = Range(match.range(at: 1), in: text) else { return nil }
|
||||
return String(text[range])
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseWorkspaceIDsFromJSON(text: String) -> [String] {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data, options: [])
|
||||
else {
|
||||
return []
|
||||
}
|
||||
var results: [String] = []
|
||||
self.collectWorkspaceIDs(object: object, out: &results)
|
||||
return results
|
||||
}
|
||||
|
||||
private static func collectWorkspaceIDs(object: Any, out: inout [String]) {
|
||||
if let dict = object as? [String: Any] {
|
||||
for (_, value) in dict {
|
||||
self.collectWorkspaceIDs(object: value, out: &out)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let array = object as? [Any] {
|
||||
for value in array {
|
||||
self.collectWorkspaceIDs(object: value, out: &out)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let string = object as? String,
|
||||
string.hasPrefix("wrk_"),
|
||||
!out.contains(string)
|
||||
{
|
||||
out.append(string)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractDouble(pattern: String, text: String) -> Double? {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil }
|
||||
let nsrange = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, options: [], range: nsrange),
|
||||
let range = Range(match.range(at: 1), in: text)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return Double(text[range])
|
||||
}
|
||||
|
||||
private static func extractInt(pattern: String, text: String) -> Int? {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil }
|
||||
let nsrange = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, options: [], range: nsrange),
|
||||
let range = Range(match.range(at: 1), in: text)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return Int(text[range])
|
||||
}
|
||||
|
||||
private static func doubleValue(from value: Any?) -> Double? {
|
||||
let number: Double? = switch value {
|
||||
case let number as Double:
|
||||
number
|
||||
case let number as NSNumber:
|
||||
number.doubleValue
|
||||
case let string as String:
|
||||
Double(string.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
nil
|
||||
}
|
||||
guard let number, number.isFinite else { return nil }
|
||||
return number
|
||||
}
|
||||
|
||||
private static func intValue(from value: Any?) -> Int? {
|
||||
switch value {
|
||||
case let number as Int:
|
||||
number
|
||||
case let number as NSNumber:
|
||||
number.intValue
|
||||
case let string as String:
|
||||
Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func looksSignedOut(text: String) -> Bool {
|
||||
let lower = text.lowercased()
|
||||
if lower.contains("login") ||
|
||||
lower.contains("sign in") ||
|
||||
lower.contains("auth/authorize") ||
|
||||
lower.contains("not associated with an account") ||
|
||||
lower.contains("actor of type \"public\"")
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func extractServerErrorMessage(from text: String) -> String? {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data, options: [])
|
||||
else {
|
||||
// If it's not JSON, try to extract error from HTML if possible
|
||||
if let match = text.range(of: #"(?i)<title>([^<]+)</title>"#, options: .regularExpression) {
|
||||
return String(text[match].dropFirst(7).dropLast(8)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let dict = object as? [String: Any] else { return nil }
|
||||
|
||||
if let message = dict["message"] as? String, !message.isEmpty {
|
||||
return message
|
||||
}
|
||||
if let error = dict["error"] as? String, !error.isEmpty {
|
||||
return error
|
||||
}
|
||||
// Check for common error fields in some frameworks
|
||||
if let detail = dict["detail"] as? String, !detail.isEmpty {
|
||||
return detail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func serverRequestURL(serverID: String, args: [Any]?, method: String) -> URL {
|
||||
guard method.uppercased() == "GET" else {
|
||||
return self.serverURL
|
||||
}
|
||||
|
||||
var components = URLComponents(url: self.serverURL, resolvingAgainstBaseURL: false)
|
||||
var queryItems = [URLQueryItem(name: "id", value: serverID)]
|
||||
if let args, !args.isEmpty,
|
||||
let data = try? JSONSerialization.data(withJSONObject: args, options: []),
|
||||
let encodedArgs = String(data: data, encoding: .utf8)
|
||||
{
|
||||
queryItems.append(URLQueryItem(name: "args", value: encodedArgs))
|
||||
}
|
||||
components?.queryItems = queryItems
|
||||
return components?.url ?? self.serverURL
|
||||
}
|
||||
|
||||
private static func parseUsageJSON(object: Any, now: Date) -> OpenCodeUsageSnapshot? {
|
||||
guard let dict = object as? [String: Any] else { return nil }
|
||||
let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys))
|
||||
if let snapshot = self.parseUsageDictionary(dict, now: now, inheritedRenewsAt: renewsAt) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
for key in ["data", "result", "usage", "billing", "payload"] {
|
||||
if let nested = dict[key] as? [String: Any],
|
||||
let snapshot = self.parseUsageDictionary(nested, now: now, inheritedRenewsAt: renewsAt)
|
||||
{
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
|
||||
if let snapshot = self.parseUsageNested(dict, now: now, depth: 0, inheritedRenewsAt: renewsAt) {
|
||||
return snapshot
|
||||
}
|
||||
return self.parseUsageFromCandidates(object: object, now: now, inheritedRenewsAt: renewsAt)
|
||||
}
|
||||
|
||||
private static func parseUsageDictionary(
|
||||
_ dict: [String: Any],
|
||||
now: Date,
|
||||
inheritedRenewsAt: Date?) -> OpenCodeUsageSnapshot?
|
||||
{
|
||||
let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt
|
||||
if let usage = dict["usage"] as? [String: Any],
|
||||
let snapshot = self.parseUsageDictionary(usage, now: now, inheritedRenewsAt: renewsAt)
|
||||
{
|
||||
return snapshot
|
||||
}
|
||||
|
||||
let rollingKeys = ["rollingUsage", "rolling", "rolling_usage", "rollingWindow", "rolling_window"]
|
||||
let weeklyKeys = ["weeklyUsage", "weekly", "weekly_usage", "weeklyWindow", "weekly_window"]
|
||||
|
||||
let rolling = rollingKeys.compactMap { dict[$0] as? [String: Any] }.first
|
||||
let weekly = weeklyKeys.compactMap { dict[$0] as? [String: Any] }.first
|
||||
|
||||
if let rolling, let weekly {
|
||||
return self.buildSnapshot(rolling: rolling, weekly: weekly, now: now, renewsAt: renewsAt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseUsageNested(
|
||||
_ dict: [String: Any],
|
||||
now: Date,
|
||||
depth: Int,
|
||||
inheritedRenewsAt: Date?) -> OpenCodeUsageSnapshot?
|
||||
{
|
||||
if depth > 3 { return nil }
|
||||
let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt
|
||||
var rolling: [String: Any]?
|
||||
var weekly: [String: Any]?
|
||||
|
||||
for (key, value) in dict {
|
||||
guard let sub = value as? [String: Any] else { continue }
|
||||
let lower = key.lowercased()
|
||||
if lower.contains("rolling") {
|
||||
rolling = sub
|
||||
} else if lower.contains("weekly") || lower.contains("week") {
|
||||
weekly = sub
|
||||
}
|
||||
}
|
||||
|
||||
if let rolling, let weekly {
|
||||
let snapshot = self.buildSnapshot(rolling: rolling, weekly: weekly, now: now, renewsAt: renewsAt)
|
||||
if let snapshot { return snapshot }
|
||||
}
|
||||
|
||||
for value in dict.values {
|
||||
if let sub = value as? [String: Any],
|
||||
let snapshot = self.parseUsageNested(
|
||||
sub,
|
||||
now: now,
|
||||
depth: depth + 1,
|
||||
inheritedRenewsAt: renewsAt)
|
||||
{
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseUsageFromCandidates(
|
||||
object: Any,
|
||||
now: Date,
|
||||
inheritedRenewsAt: Date? = nil) -> OpenCodeUsageSnapshot?
|
||||
{
|
||||
let candidates = self.collectWindowCandidates(object: object, now: now)
|
||||
guard !candidates.isEmpty else { return nil }
|
||||
|
||||
let rollingCandidates = candidates.filter { candidate in
|
||||
candidate.pathLower.contains("rolling") ||
|
||||
candidate.pathLower.contains("hour") ||
|
||||
candidate.pathLower.contains("5h") ||
|
||||
candidate.pathLower.contains("5-hour")
|
||||
}
|
||||
let weeklyCandidates = candidates.filter { candidate in
|
||||
candidate.pathLower.contains("weekly") ||
|
||||
candidate.pathLower.contains("week")
|
||||
}
|
||||
|
||||
let rolling = self.pickCandidate(
|
||||
preferred: rollingCandidates,
|
||||
fallback: candidates,
|
||||
pickShorter: true)
|
||||
let weekly = self.pickCandidate(
|
||||
preferred: weeklyCandidates,
|
||||
fallback: candidates,
|
||||
pickShorter: false,
|
||||
excluding: rolling?.id)
|
||||
|
||||
guard let rolling, let weekly else { return nil }
|
||||
|
||||
let renewsAt = self.dateValue(from: self.value(from: object as? [String: Any] ?? [:], keys: self.renewAtKeys))
|
||||
?? inheritedRenewsAt
|
||||
return OpenCodeUsageSnapshot(
|
||||
rollingUsagePercent: rolling.percent,
|
||||
weeklyUsagePercent: weekly.percent,
|
||||
rollingResetInSec: rolling.resetInSec,
|
||||
weeklyResetInSec: weekly.resetInSec,
|
||||
renewsAt: renewsAt,
|
||||
updatedAt: now)
|
||||
}
|
||||
|
||||
private struct WindowCandidate {
|
||||
let id: UUID
|
||||
let percent: Double
|
||||
let resetInSec: Int
|
||||
let pathLower: String
|
||||
}
|
||||
|
||||
private static func collectWindowCandidates(object: Any, now: Date) -> [WindowCandidate] {
|
||||
var candidates: [WindowCandidate] = []
|
||||
self.collectWindowCandidates(object: object, now: now, path: [], out: &candidates)
|
||||
return candidates
|
||||
}
|
||||
|
||||
private static func collectWindowCandidates(
|
||||
object: Any,
|
||||
now: Date,
|
||||
path: [String],
|
||||
out: inout [WindowCandidate])
|
||||
{
|
||||
if let dict = object as? [String: Any] {
|
||||
if let window = self.parseWindow(dict, now: now) {
|
||||
let pathLower = path.joined(separator: ".").lowercased()
|
||||
out.append(WindowCandidate(
|
||||
id: UUID(),
|
||||
percent: window.percent,
|
||||
resetInSec: window.resetInSec,
|
||||
pathLower: pathLower))
|
||||
}
|
||||
for (key, value) in dict {
|
||||
self.collectWindowCandidates(object: value, now: now, path: path + [key], out: &out)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let array = object as? [Any] {
|
||||
for (index, value) in array.enumerated() {
|
||||
self.collectWindowCandidates(
|
||||
object: value,
|
||||
now: now,
|
||||
path: path + ["[\(index)]"],
|
||||
out: &out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func pickCandidate(
|
||||
preferred: [WindowCandidate],
|
||||
fallback: [WindowCandidate],
|
||||
pickShorter: Bool,
|
||||
excluding excluded: UUID? = nil) -> WindowCandidate?
|
||||
{
|
||||
let filteredPreferred = preferred.filter { $0.id != excluded }
|
||||
if let picked = self.pickCandidate(from: filteredPreferred, pickShorter: pickShorter) {
|
||||
return picked
|
||||
}
|
||||
let filteredFallback = fallback.filter { $0.id != excluded }
|
||||
return self.pickCandidate(from: filteredFallback, pickShorter: pickShorter)
|
||||
}
|
||||
|
||||
private static func pickCandidate(from candidates: [WindowCandidate], pickShorter: Bool) -> WindowCandidate? {
|
||||
guard !candidates.isEmpty else { return nil }
|
||||
let comparator: (WindowCandidate, WindowCandidate) -> Bool = { lhs, rhs in
|
||||
if pickShorter {
|
||||
if lhs.resetInSec == rhs.resetInSec { return lhs.percent > rhs.percent }
|
||||
return lhs.resetInSec < rhs.resetInSec
|
||||
}
|
||||
if lhs.resetInSec == rhs.resetInSec { return lhs.percent > rhs.percent }
|
||||
return lhs.resetInSec > rhs.resetInSec
|
||||
}
|
||||
return candidates.min(by: comparator)
|
||||
}
|
||||
|
||||
private static func buildSnapshot(
|
||||
rolling: [String: Any],
|
||||
weekly: [String: Any],
|
||||
now: Date,
|
||||
renewsAt: Date? = nil) -> OpenCodeUsageSnapshot?
|
||||
{
|
||||
guard let rollingWindow = self.parseWindow(rolling, now: now),
|
||||
let weeklyWindow = self.parseWindow(weekly, now: now)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return OpenCodeUsageSnapshot(
|
||||
rollingUsagePercent: rollingWindow.percent,
|
||||
weeklyUsagePercent: weeklyWindow.percent,
|
||||
rollingResetInSec: rollingWindow.resetInSec,
|
||||
weeklyResetInSec: weeklyWindow.resetInSec,
|
||||
renewsAt: renewsAt,
|
||||
updatedAt: now)
|
||||
}
|
||||
|
||||
private static func parseWindow(_ dict: [String: Any], now: Date) -> (percent: Double, resetInSec: Int)? {
|
||||
var percent = self.doubleValue(from: dict, keys: self.percentKeys)
|
||||
|
||||
if percent == nil {
|
||||
let used = self.doubleValue(from: dict, keys: ["used", "usage", "consumed", "count", "usedTokens"])
|
||||
let limit = self.doubleValue(from: dict, keys: ["limit", "total", "quota", "max", "cap", "tokenLimit"])
|
||||
if let used, let limit, limit > 0 {
|
||||
percent = (used / limit) * 100
|
||||
}
|
||||
}
|
||||
|
||||
guard var resolvedPercent = percent else { return nil }
|
||||
if resolvedPercent <= 1.0, resolvedPercent >= 0 {
|
||||
resolvedPercent *= 100
|
||||
}
|
||||
resolvedPercent = max(0, min(100, resolvedPercent))
|
||||
|
||||
var resetInSec = self.intValue(from: dict, keys: self.resetInKeys)
|
||||
if resetInSec == nil {
|
||||
let resetAtValue = self.value(from: dict, keys: self.resetAtKeys)
|
||||
if let resetAt = self.dateValue(from: resetAtValue),
|
||||
let interval = self.resetInterval(from: resetAt, now: now)
|
||||
{
|
||||
resetInSec = interval
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedReset = max(0, resetInSec ?? 0)
|
||||
return (resolvedPercent, resolvedReset)
|
||||
}
|
||||
|
||||
private static func doubleValue(from dict: [String: Any], keys: [String]) -> Double? {
|
||||
for key in keys {
|
||||
if let value = self.doubleValue(from: dict[key]) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func intValue(from dict: [String: Any], keys: [String]) -> Int? {
|
||||
for key in keys {
|
||||
if let value = self.intValue(from: dict[key]) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func value(from dict: [String: Any], keys: [String]) -> Any? {
|
||||
for key in keys {
|
||||
if let value = dict[key] {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func dateValue(from value: Any?) -> Date? {
|
||||
guard let value else { return nil }
|
||||
if let number = self.doubleValue(from: value) {
|
||||
if number > 1_000_000_000_000 {
|
||||
return Date(timeIntervalSince1970: number / 1000)
|
||||
}
|
||||
if number > 1_000_000_000 {
|
||||
return Date(timeIntervalSince1970: number)
|
||||
}
|
||||
}
|
||||
if let string = value as? String {
|
||||
if let number = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
return self.dateValue(from: number)
|
||||
}
|
||||
if let parsed = self.makeISO8601Formatter().date(from: string) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resetInterval(from resetAt: Date, now: Date) -> Int? {
|
||||
let interval = resetAt.timeIntervalSince(now)
|
||||
guard interval.isFinite else { return nil }
|
||||
if interval <= 0 { return 0 }
|
||||
guard interval < Double(Int.max) else { return nil }
|
||||
return Int(interval)
|
||||
}
|
||||
|
||||
private static func logParseSummary(text: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let data = text.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data, options: [])
|
||||
else {
|
||||
let hint = if trimmed.hasPrefix("<") {
|
||||
"html"
|
||||
} else if trimmed.hasPrefix("{") || trimmed.hasPrefix("[") {
|
||||
"json"
|
||||
} else if trimmed.isEmpty {
|
||||
"empty"
|
||||
} else {
|
||||
"text"
|
||||
}
|
||||
Self.log.error("OpenCode response non-JSON: hint=\(hint) length=\(text.count)")
|
||||
return
|
||||
}
|
||||
self.logParseSummary(object: object)
|
||||
}
|
||||
|
||||
private static func logParseSummary(object: Any) {
|
||||
let summary = self.summarizeJSON(object: object, depth: 0)
|
||||
guard !summary.isEmpty else { return }
|
||||
Self.log.error("OpenCode response summary: \(summary)")
|
||||
}
|
||||
|
||||
private static func summarizeJSON(object: Any, depth: Int) -> String {
|
||||
if depth > 3 { return "" }
|
||||
if let dict = object as? [String: Any] {
|
||||
let keys = dict.keys.sorted()
|
||||
var parts: [String] = []
|
||||
for key in keys {
|
||||
let value = dict[key]
|
||||
let type = self.valueTypeDescription(value, depth: depth + 1)
|
||||
parts.append("\(key):\(type)")
|
||||
}
|
||||
return "{\(parts.joined(separator: ", "))}"
|
||||
}
|
||||
if let array = object as? [Any] {
|
||||
guard let first = array.first else { return "[]" }
|
||||
let type = self.valueTypeDescription(first, depth: depth + 1)
|
||||
return "[\(type)]"
|
||||
}
|
||||
return self.scalarTypeDescription(object)
|
||||
}
|
||||
|
||||
private static func valueTypeDescription(_ value: Any?, depth: Int) -> String {
|
||||
guard let value else { return "null" }
|
||||
if let dict = value as? [String: Any] {
|
||||
return self.summarizeJSON(object: dict, depth: depth)
|
||||
}
|
||||
if let array = value as? [Any] {
|
||||
return self.summarizeJSON(object: array, depth: depth)
|
||||
}
|
||||
return self.scalarTypeDescription(value)
|
||||
}
|
||||
|
||||
private static func scalarTypeDescription(_ value: Any) -> String {
|
||||
switch value {
|
||||
case is String: "string"
|
||||
case is Bool: "bool"
|
||||
case is Int, is Double, is NSNumber: "number"
|
||||
default: "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
public struct OpenCodeUsageSnapshot: Sendable {
|
||||
public let rollingUsagePercent: Double
|
||||
public let weeklyUsagePercent: Double
|
||||
public let rollingResetInSec: Int
|
||||
public let weeklyResetInSec: Int
|
||||
public let renewsAt: Date?
|
||||
public let updatedAt: Date
|
||||
|
||||
public init(
|
||||
rollingUsagePercent: Double,
|
||||
weeklyUsagePercent: Double,
|
||||
rollingResetInSec: Int,
|
||||
weeklyResetInSec: Int,
|
||||
renewsAt: Date? = nil,
|
||||
updatedAt: Date)
|
||||
{
|
||||
self.rollingUsagePercent = rollingUsagePercent
|
||||
self.weeklyUsagePercent = weeklyUsagePercent
|
||||
self.rollingResetInSec = rollingResetInSec
|
||||
self.weeklyResetInSec = weeklyResetInSec
|
||||
self.renewsAt = renewsAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
public func toUsageSnapshot() -> UsageSnapshot {
|
||||
let rollingReset = self.updatedAt.addingTimeInterval(TimeInterval(self.rollingResetInSec))
|
||||
let weeklyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.weeklyResetInSec))
|
||||
|
||||
let primary = RateWindow(
|
||||
usedPercent: self.rollingUsagePercent,
|
||||
windowMinutes: 5 * 60,
|
||||
resetsAt: rollingReset,
|
||||
resetDescription: nil)
|
||||
let secondary = RateWindow(
|
||||
usedPercent: self.weeklyUsagePercent,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: weeklyReset,
|
||||
resetDescription: nil)
|
||||
|
||||
var extraWindows: [NamedRateWindow]?
|
||||
if let renewsAt = self.renewsAt {
|
||||
let renewalWindow = RateWindow(
|
||||
usedPercent: 0,
|
||||
windowMinutes: nil,
|
||||
resetsAt: renewsAt,
|
||||
resetDescription: nil)
|
||||
extraWindows = [NamedRateWindow(id: "renewal", title: "Renews", window: renewalWindow)]
|
||||
}
|
||||
|
||||
return UsageSnapshot(
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
extraRateWindows: extraWindows,
|
||||
updatedAt: self.updatedAt,
|
||||
identity: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
enum OpenCodeWebCookieSupport {
|
||||
private static let requestCookieNames: Set<String> = ["auth", "__Host-auth"]
|
||||
|
||||
struct Context {
|
||||
let settings: ProviderSettingsSnapshot.OpenCodeProviderSettings?
|
||||
let provider: UsageProvider
|
||||
let browserDetection: BrowserDetection
|
||||
let allowCached: Bool
|
||||
}
|
||||
|
||||
static func requestCookieHeader(from rawHeader: String?) -> String? {
|
||||
CookieHeaderNormalizer.filteredHeader(from: rawHeader, allowedNames: self.requestCookieNames)
|
||||
}
|
||||
|
||||
static func resolveCookieHeader(
|
||||
context: Context,
|
||||
invalidCookie: @autoclosure () -> Error,
|
||||
missingCookie: @autoclosure () -> Error) throws -> String
|
||||
{
|
||||
if let settings = context.settings, settings.cookieSource == .manual {
|
||||
if let header = self.requestCookieHeader(from: settings.manualCookieHeader) {
|
||||
return header
|
||||
}
|
||||
throw invalidCookie()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if context.allowCached,
|
||||
let cached = CookieHeaderCache.load(provider: context.provider),
|
||||
let header = self.requestCookieHeader(from: cached.cookieHeader)
|
||||
{
|
||||
return header
|
||||
}
|
||||
let session = try OpenCodeCookieImporter.importSession(
|
||||
browserDetection: context.browserDetection,
|
||||
preferredBrowsers: self.automaticImportOrder(provider: context.provider))
|
||||
guard let header = self.requestCookieHeader(from: session.cookieHeader) else {
|
||||
throw missingCookie()
|
||||
}
|
||||
CookieHeaderCache.store(
|
||||
provider: context.provider,
|
||||
cookieHeader: header,
|
||||
sourceLabel: session.sourceLabel)
|
||||
return header
|
||||
#else
|
||||
throw missingCookie()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
static func automaticImportOrder(provider: UsageProvider) -> BrowserCookieImportOrder {
|
||||
ProviderDefaults.metadata[provider]?.browserCookieOrder ?? [.chrome]
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user