codeburn/mac/Sources/CodeBurnMenubar/Data/ZaiPlanQuotaDecoder.swift
ozymandiashh 3e87863d44
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
Windows Menubar CI / check (ubuntu-latest) (push) Waiting to run
Windows Menubar CI / check (windows-latest) (push) Waiting to run
feat(quota): add ZCode (z.ai coding plan) live quota provider (#1347)
Adds a live quota gauge for the z.ai ZCode coding plan on all three surfaces: the CLI plan view, the desktop Plans page, and the mac menu bar. It reads the provider's own remaining-allowance endpoint, so it is quota-only and touches no cost or spend paths. One decoder is shared between Z.ai and ZCode, and ZCode is hidden when a Z.ai login already covers the same endpoint. The fetch is bounded with graceful disconnected, waiting and error states, and no socket is opened without a login. The logo shows on every surface, and ZCode is listed in the desktop supported-provider list to match the menu bar catalog.
2026-09-18 18:11:08 -07:00

111 lines
4.9 KiB
Swift

import Foundation
/// Shared decoder for Z.ai's `usage/quota/limit` response body. The Z.ai
/// adapter (Pi CLI credential, bare Authorization header) and the ZCode
/// adapter (coding-plan app login, Bearer token) hit the same endpoint with
/// different credentials and receive byte-identical bodies, so the window
/// extraction lives here once and each service maps the outcome onto its own
/// `FetchError` vocabulary.
enum ZaiPlanQuotaDecoder {
enum DecodeError: Error, Equatable, Sendable {
case authenticationRejected
case parseFailure
}
/// The endpoint answers HTTP 200 even for a dead login, with a
/// business-level `code` of 401/403 in the body — that code is the only
/// expiry signal either adapter gets.
static func decode(_ data: Data) throws -> QuotaSummary {
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw DecodeError.parseFailure
}
let bodyCode = jsonNumber(root["code"]).map(Int.init)
if bodyCode == 401 || bodyCode == 403 { throw DecodeError.authenticationRejected }
if root["success"] as? Bool == false { throw DecodeError.parseFailure }
let payload = (root["data"] as? [String: Any]) ?? root
guard let limits = payload["limits"] as? [Any] else { throw DecodeError.parseFailure }
var fiveHour: QuotaSummary.Window?
var weekly: QuotaSummary.Window?
for raw in limits {
guard let limit = raw as? [String: Any],
let type = limit["type"] as? String,
type == "CREDIT_LIMIT" || type == "TOKENS_LIMIT",
let unit = jsonNumber(limit["unit"]),
let count = jsonNumber(limit["number"]) else { continue }
let label: String
/// Fixed cycle length derived from the unit/count enum the payload
/// carries — provider metadata, not the label. 5-hour and weekly
/// windows reset on fixed instants (the unit enum says so), which
/// is what the early-reset monitor's windowSeconds contract needs:
/// an adapter that cannot vouch for fixed cycling passes no length.
let cycleSeconds: Int
if unit == 3, count == 5 {
label = "5-hour"
cycleSeconds = 5 * 3600
} else if unit == 6, count == 1 {
label = "Weekly"
cycleSeconds = 7 * 24 * 3600
} else {
continue
}
var usedPercent = jsonNumber(limit["percentage"])
if usedPercent == nil,
let current = jsonNumber(limit["currentValue"]),
let total = jsonNumber(limit["usage"]), total > 0 {
usedPercent = current / total * 100
}
guard let usedPercent else { continue }
let window = QuotaSummary.Window(
label: label,
percent: min(1, max(0, usedPercent / 100)),
resetsAt: parseReset(limit["nextResetTime"]),
windowSeconds: cycleSeconds
)
if label == "Weekly" { weekly = window } else { fiveHour = window }
}
let details = [fiveHour, weekly].compactMap { $0 }
guard !details.isEmpty else { throw DecodeError.parseFailure }
return QuotaSummary(
providerFilter: .all,
connection: .connected,
primary: weekly ?? fiveHour,
details: details,
planLabel: planLabel(payload["level"]),
footerLines: ["Source: Z.ai Coding Plan"]
)
}
private static func jsonNumber(_ value: Any?) -> Double? {
if let value = value as? Double, value.isFinite { return value }
if let value = value as? NSNumber { return value.doubleValue }
if let value = value as? String, let number = Double(value), number.isFinite { return number }
return nil
}
private static func parseReset(_ value: Any?) -> Date? {
if let number = jsonNumber(value) {
let seconds = number < 1_000_000_000_000 ? number : number / 1000
return seconds.isFinite ? Date(timeIntervalSince1970: seconds) : nil
}
guard let value = value as? String else { return nil }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
private static func planLabel(_ value: Any?) -> String? {
guard let value = value as? String else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed.replacingOccurrences(of: "_", with: " ").lowercased().capitalized
}
}