Files
2026-07-13 12:22:33 +08:00

845 lines
29 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Z.ai usage limit types from the API
public enum ZaiLimitType: String, Sendable {
case timeLimit = "TIME_LIMIT"
case tokensLimit = "TOKENS_LIMIT"
}
/// Z.ai usage limit unit types
public enum ZaiLimitUnit: Int, Sendable {
case unknown = 0
case days = 1
case hours = 3
case minutes = 5
case weeks = 6
}
public enum ZaiUsageScope: String, CaseIterable, Codable, Sendable {
case personal
case team
}
public struct ZaiBigModelTeamContext: Equatable, Sendable {
public let organizationID: String
public let projectID: String
public init?(organizationID: String?, projectID: String?) {
guard let organizationID = ZaiSettingsReader.cleaned(organizationID),
let projectID = ZaiSettingsReader.cleaned(projectID)
else {
return nil
}
self.organizationID = organizationID
self.projectID = projectID
}
public init?(environment: [String: String] = ProcessInfo.processInfo.environment) {
self.init(
organizationID: environment[ZaiSettingsReader.bigModelOrganizationKey],
projectID: environment[ZaiSettingsReader.bigModelProjectKey])
}
}
/// A single limit entry from the z.ai API
public struct ZaiLimitEntry: Sendable {
public let type: ZaiLimitType
public let unit: ZaiLimitUnit
public let number: Int
public let usage: Int?
public let currentValue: Int?
public let remaining: Int?
public let percentage: Double
public let usageDetails: [ZaiUsageDetail]
public let nextResetTime: Date?
public init(
type: ZaiLimitType,
unit: ZaiLimitUnit,
number: Int,
usage: Int?,
currentValue: Int?,
remaining: Int?,
percentage: Double,
usageDetails: [ZaiUsageDetail],
nextResetTime: Date?)
{
self.type = type
self.unit = unit
self.number = number
self.usage = usage
self.currentValue = currentValue
self.remaining = remaining
self.percentage = percentage
self.usageDetails = usageDetails
self.nextResetTime = nextResetTime
}
}
extension ZaiLimitEntry {
public var usedPercent: Double {
if let computed = self.computedUsedPercent {
return computed
}
return self.percentage
}
public var windowMinutes: Int? {
guard self.number > 0 else { return nil }
switch self.unit {
case .minutes:
return self.number
case .hours:
return self.number * 60
case .days:
return self.number * 24 * 60
case .weeks:
return self.number * 7 * 24 * 60
case .unknown:
return nil
}
}
public var windowDescription: String? {
guard self.number > 0 else { return nil }
let unitLabel: String? = switch self.unit {
case .minutes: "minute"
case .hours: "hour"
case .days: "day"
case .weeks: "week"
case .unknown: nil
}
guard let unitLabel else { return nil }
let suffix = self.number == 1 ? unitLabel : "\(unitLabel)s"
return "\(self.number) \(suffix)"
}
public var windowLabel: String? {
guard let description = self.windowDescription else { return nil }
return "\(description) window"
}
var isMCPMonthlyMarker: Bool {
self.type == .timeLimit && self.unit == .minutes && self.number == 1
}
private var computedUsedPercent: Double? {
guard let limit = self.usage, limit > 0 else { return nil }
// z.ai sometimes omits quota fields; don't invent zeros (can yield 100% used incorrectly).
var usedRaw: Int?
if let remaining = self.remaining {
let usedFromRemaining = limit - remaining
if let currentValue = self.currentValue {
usedRaw = max(usedFromRemaining, currentValue)
} else {
usedRaw = usedFromRemaining
}
} else if let currentValue = self.currentValue {
usedRaw = currentValue
}
guard let usedRaw else { return nil }
let used = max(0, min(limit, usedRaw))
let percent = (Double(used) / Double(limit)) * 100
return min(100, max(0, percent))
}
}
/// Usage detail for MCP tools
public struct ZaiUsageDetail: Sendable, Codable {
public let modelCode: String
public let usage: Int
public init(modelCode: String, usage: Int) {
self.modelCode = modelCode
self.usage = usage
}
}
/// Complete z.ai usage response
public struct ZaiUsageSnapshot: Sendable {
public let tokenLimit: ZaiLimitEntry?
/// Shorter-window TOKENS_LIMIT (e.g. 5-hour), present only when the API returns two TOKENS_LIMIT entries.
public let sessionTokenLimit: ZaiLimitEntry?
public let timeLimit: ZaiLimitEntry?
public let planName: String?
public let modelUsage: ZaiModelUsageData?
public let updatedAt: Date
public init(
tokenLimit: ZaiLimitEntry?,
sessionTokenLimit: ZaiLimitEntry? = nil,
timeLimit: ZaiLimitEntry?,
planName: String?,
modelUsage: ZaiModelUsageData? = nil,
updatedAt: Date)
{
self.tokenLimit = tokenLimit
self.sessionTokenLimit = sessionTokenLimit
self.timeLimit = timeLimit
self.planName = planName
self.modelUsage = modelUsage
self.updatedAt = updatedAt
}
/// Returns true if this snapshot contains valid z.ai data
public var isValid: Bool {
self.tokenLimit != nil || self.timeLimit != nil
}
}
extension ZaiUsageSnapshot {
public func toUsageSnapshot() -> UsageSnapshot {
let primaryLimit = self.tokenLimit ?? self.timeLimit
let secondaryLimit = (self.tokenLimit != nil && self.timeLimit != nil) ? self.timeLimit : nil
let primary = primaryLimit.map { Self.rateWindow(for: $0) } ?? RateWindow(
usedPercent: 0,
windowMinutes: nil,
resetsAt: nil,
resetDescription: nil)
let secondary = secondaryLimit.map { Self.rateWindow(for: $0) }
let tertiary = self.sessionTokenLimit.map { Self.rateWindow(for: $0) }
let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines)
let loginMethod = (planName?.isEmpty ?? true) ? nil : planName
let identity = ProviderIdentitySnapshot(
providerID: .zai,
accountEmail: nil,
accountOrganization: nil,
loginMethod: loginMethod)
return UsageSnapshot(
primary: primary,
secondary: secondary,
tertiary: tertiary,
providerCost: nil,
zaiUsage: self,
updatedAt: self.updatedAt,
identity: identity)
}
private static func rateWindow(for limit: ZaiLimitEntry) -> RateWindow {
RateWindow(
usedPercent: limit.usedPercent,
windowMinutes: limit.type == .tokensLimit ? limit.windowMinutes : nil,
resetsAt: limit.nextResetTime,
resetDescription: self.resetDescription(for: limit))
}
private static func resetDescription(for limit: ZaiLimitEntry) -> String? {
if limit.isMCPMonthlyMarker {
return "Monthly"
}
if let label = limit.windowLabel {
return label
}
if limit.type == .timeLimit {
return "Monthly"
}
return nil
}
}
/// Z.ai quota limit API response
private struct ZaiQuotaLimitResponse: Decodable {
let code: Int
let msg: String?
let data: ZaiQuotaLimitData?
let success: Bool
var isSuccess: Bool {
self.success && self.code == 200
}
var errorMessage: String {
let message = self.msg?.trimmingCharacters(in: .whitespacesAndNewlines)
if let message, !message.isEmpty { return message }
return "Z.ai quota API returned code \(self.code)"
}
}
private struct ZaiQuotaLimitData: Decodable {
let limits: [ZaiLimitRaw]
let planName: String?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.limits = try container.decodeIfPresent([ZaiLimitRaw].self, forKey: .limits) ?? []
let rawPlan = try [
container.decodeIfPresent(String.self, forKey: .planName),
container.decodeIfPresent(String.self, forKey: .plan),
container.decodeIfPresent(String.self, forKey: .planType),
container.decodeIfPresent(String.self, forKey: .packageName),
].compactMap(\.self).first
let trimmed = rawPlan?.trimmingCharacters(in: .whitespacesAndNewlines)
self.planName = (trimmed?.isEmpty ?? true) ? nil : trimmed
}
private enum CodingKeys: String, CodingKey {
case limits
case planName
case plan
case planType = "plan_type"
case packageName
}
}
private struct ZaiLimitRaw: Codable {
let type: String
let unit: Int
let number: Int
let usage: Int?
let currentValue: Int?
let remaining: Int?
let percentage: Int
let usageDetails: [ZaiUsageDetail]?
let nextResetTime: Int?
func toLimitEntry() -> ZaiLimitEntry? {
guard let limitType = ZaiLimitType(rawValue: type) else { return nil }
let limitUnit = ZaiLimitUnit(rawValue: unit) ?? .unknown
let nextReset = self.nextResetTime.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
return ZaiLimitEntry(
type: limitType,
unit: limitUnit,
number: self.number,
usage: self.usage,
currentValue: self.currentValue,
remaining: self.remaining,
percentage: Double(self.percentage),
usageDetails: self.usageDetails ?? [],
nextResetTime: nextReset)
}
}
/// Fetches usage stats from the z.ai API
public struct ZaiUsageFetcher: Sendable {
private static let log = CodexBarLog.logger(LogCategories.zaiUsage)
/// Path for z.ai quota API
private static let quotaAPIPath = "api/monitor/usage/quota/limit"
/// Resolves the quota URL using (in order):
/// 1) `Z_AI_QUOTA_URL` environment override (full URL).
/// 2) `Z_AI_API_HOST` environment override (host/base URL).
/// 3) Region selection (global default).
public static func resolveQuotaURL(
region: ZaiAPIRegion,
environment: [String: String] = ProcessInfo.processInfo.environment) -> URL
{
if let override = ZaiSettingsReader.quotaURL(environment: environment) {
return override
}
if let host = ZaiSettingsReader.apiHost(environment: environment),
let hostURL = self.quotaURL(baseURLString: host)
{
return hostURL
}
return region.quotaLimitURL
}
/// Resolves the canonical dashboard for the effective quota endpoint without opening custom override hosts.
public static func resolveDashboardURL(
region: ZaiAPIRegion,
environment: [String: String] = ProcessInfo.processInfo.environment,
usageScope: ZaiUsageScope = .personal) -> URL
{
let quotaHost = self.resolveQuotaURL(region: region, environment: environment).host?.lowercased()
if quotaHost == ZaiAPIRegion.global.quotaLimitURL.host?.lowercased() {
return usageScope == .team ? ZaiAPIRegion.global.teamDashboardURL : ZaiAPIRegion.global.dashboardURL
}
if quotaHost == ZaiAPIRegion.bigmodelCN.quotaLimitURL.host?.lowercased() {
return usageScope == .team ? ZaiAPIRegion.bigmodelCN.teamDashboardURL : ZaiAPIRegion.bigmodelCN.dashboardURL
}
return usageScope == .team ? region.teamDashboardURL : region.dashboardURL
}
/// Fetches usage stats from z.ai using the provided API key
public static func fetchUsage(
apiKey: String,
region: ZaiAPIRegion = .global,
usageScope: ZaiUsageScope? = nil,
teamContext: ZaiBigModelTeamContext? = nil,
environment: [String: String] = ProcessInfo.processInfo.environment,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZaiUsageSnapshot
{
guard !apiKey.isEmpty else {
throw ZaiUsageError.invalidCredentials
}
try ZaiSettingsReader.validateQuotaEndpointOverride(environment: environment)
let resolvedScope = usageScope ?? .personal
let quotaURL = try self.requestURL(
baseURL: self.resolveQuotaURL(region: region, environment: environment),
usageScope: resolvedScope)
let resolvedTeamContext = try self.resolvedTeamContext(
usageScope: resolvedScope,
explicit: teamContext,
environment: environment)
var request = URLRequest(url: quotaURL)
request.httpMethod = "GET"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "accept")
self.applyTeamHeaders(resolvedTeamContext, to: &request)
let response = try await transport.response(for: request)
let data = response.data
guard response.statusCode == 200 else {
let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
Self.log.error("z.ai API returned \(response.statusCode): \(errorMessage)")
throw ZaiUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)")
}
// Some upstream issues (wrong endpoint/region/proxy) can yield HTTP 200 with an empty body.
// JSONDecoder will otherwise throw an opaque Cocoa error ("data is missing").
guard !data.isEmpty else {
Self.log.error("z.ai API returned empty body (HTTP 200) for \(Self.safeURLForLogging(quotaURL))")
throw ZaiUsageError.parseFailed(
"Empty response body (HTTP 200). Check z.ai API region (Global vs BigModel CN) and your API token.")
}
// Log raw response for debugging
if let jsonString = String(data: data, encoding: .utf8) {
Self.log.debug("z.ai API response: \(jsonString)")
}
do {
return try Self.parseUsageSnapshot(from: data)
} catch let error as DecodingError {
Self.log.error("z.ai JSON decoding error: \(error.localizedDescription)")
throw ZaiUsageError.parseFailed(error.localizedDescription)
} catch let error as ZaiUsageError {
throw error
} catch {
Self.log.error("z.ai parsing error: \(error.localizedDescription)")
throw ZaiUsageError.parseFailed(error.localizedDescription)
}
}
private static func safeURLForLogging(_ url: URL) -> String {
let host = url.host ?? "<unknown-host>"
let port = url.port.map { ":\($0)" } ?? ""
let path = url.path.isEmpty ? "/" : url.path
return "\(host)\(port)\(path)"
}
private static func requestURL(baseURL: URL, usageScope: ZaiUsageScope) throws -> URL {
guard usageScope == .team else { return baseURL }
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
throw ZaiUsageError.networkError("Invalid URL")
}
var items = components.queryItems ?? []
items.removeAll { $0.name == "type" }
items.append(URLQueryItem(name: "type", value: "2"))
components.queryItems = items
guard let url = components.url else {
throw ZaiUsageError.networkError("Invalid URL")
}
return url
}
private static func resolvedTeamContext(
usageScope: ZaiUsageScope,
explicit: ZaiBigModelTeamContext?,
environment: [String: String]) throws -> ZaiBigModelTeamContext?
{
guard usageScope == .team else { return nil }
if let explicit { return explicit }
if let context = ZaiBigModelTeamContext(environment: environment) { return context }
throw ZaiUsageError.missingTeamContext
}
private static func applyTeamHeaders(_ context: ZaiBigModelTeamContext?, to request: inout URLRequest) {
guard let context else { return }
request.setValue(context.organizationID, forHTTPHeaderField: "Bigmodel-Organization")
request.setValue(context.projectID, forHTTPHeaderField: "Bigmodel-Project")
}
static func parseUsageSnapshot(from data: Data) throws -> ZaiUsageSnapshot {
guard !data.isEmpty else {
throw ZaiUsageError.parseFailed("Empty response body")
}
let decoder = JSONDecoder()
let apiResponse = try decoder.decode(ZaiQuotaLimitResponse.self, from: data)
guard apiResponse.isSuccess else {
throw ZaiUsageError.apiError(apiResponse.errorMessage)
}
guard let responseData = apiResponse.data else {
throw ZaiUsageError.parseFailed("Missing data")
}
var tokenLimits: [ZaiLimitEntry] = []
var timeLimit: ZaiLimitEntry?
for limit in responseData.limits {
if let entry = limit.toLimitEntry() {
switch entry.type {
case .tokensLimit:
tokenLimits.append(entry)
case .timeLimit:
timeLimit = entry
}
}
}
// Multiple TOKENS_LIMIT entries: shortest window → sessionTokenLimit (tertiary),
// longest → tokenLimit (primary).
let tokenLimit: ZaiLimitEntry?
let sessionTokenLimit: ZaiLimitEntry?
if tokenLimits.count >= 2 {
let sorted = tokenLimits.sorted {
($0.windowMinutes ?? Int.max) < ($1.windowMinutes ?? Int.max)
}
sessionTokenLimit = sorted.first
tokenLimit = sorted.last
} else {
tokenLimit = tokenLimits.first
sessionTokenLimit = nil
}
return ZaiUsageSnapshot(
tokenLimit: tokenLimit,
sessionTokenLimit: sessionTokenLimit,
timeLimit: timeLimit,
planName: responseData.planName,
modelUsage: nil,
updatedAt: Date())
}
private static func quotaURL(baseURLString: String) -> URL? {
guard let cleaned = ZaiSettingsReader.cleaned(baseURLString) else { return nil }
guard let url = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) else { return nil }
if url.path.isEmpty || url.path == "/" {
return url.appendingPathComponent(Self.quotaAPIPath)
}
return url
}
}
// MARK: - Model Usage Data
/// Per-model hourly token usage from the z.ai model-usage API
public struct ZaiModelUsageData: Sendable {
public let xTime: [String]
public let modelDataList: [ZaiModelDataItem]
public init(xTime: [String], modelDataList: [ZaiModelDataItem]) {
self.xTime = xTime
self.modelDataList = modelDataList
}
public var modelNames: [String] {
self.modelDataList.compactMap(\.modelName)
}
}
public struct ZaiModelDataItem: Sendable {
public let modelName: String?
public let tokensUsage: [Int?]
public init(modelName: String?, tokensUsage: [Int?]) {
self.modelName = modelName
self.tokensUsage = tokensUsage
}
}
// MARK: - Hourly Chart Data
public enum ZaiHourlyRange: Equatable, Sendable {
case today(referenceDate: Date)
case last24h
public var isToday: Bool {
if case .today = self { return true }
return false
}
}
public struct ZaiHourlyBar: Sendable {
public let label: String
public let segments: [(model: String, tokens: Int)]
public init(label: String, segments: [(model: String, tokens: Int)]) {
self.label = label
self.segments = segments
}
public var totalTokens: Int {
self.segments.reduce(0) { $0 + $1.tokens }
}
}
public enum ZaiHourlyBars: Sendable {
public static func from(modelData: ZaiModelUsageData, range: ZaiHourlyRange, now: Date = Date()) -> [ZaiHourlyBar] {
let calendar = Calendar.current
let referenceDate: Date = switch range {
case let .today(ref): ref
case .last24h: now
}
let todayStart = calendar.startOfDay(for: referenceDate)
let cutoff: Date = switch range {
case .today: todayStart
case .last24h: calendar.date(byAdding: .hour, value: -24, to: now) ?? now
}
var bars: [ZaiHourlyBar] = []
for (index, timeString) in modelData.xTime.enumerated() {
guard let hourDate = parseHourDate(timeString) else { continue }
if hourDate < cutoff { continue }
var segments: [(model: String, tokens: Int)] = []
for item in modelData.modelDataList {
guard index < item.tokensUsage.count,
let tokenCount = item.tokensUsage[index], tokenCount > 0
else { continue }
segments.append((model: item.modelName ?? "Unknown", tokens: tokenCount))
}
let total = segments.reduce(0) { $0 + $1.tokens }
guard total > 0 else { continue }
let label = self.formatHourLabel(hourDate: hourDate)
bars.append(ZaiHourlyBar(label: label, segments: segments))
}
return bars
}
public static func parseHourDate(_ string: String) -> Date? {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.date(from: string)
}
private static func formatHourLabel(hourDate: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.string(from: hourDate)
}
}
// MARK: - Model Usage Fetcher Extension
extension ZaiUsageFetcher {
/// Fetches hourly model usage data for the last 24 hours
public static func fetchModelUsage(
apiKey: String,
region: ZaiAPIRegion = .global,
usageScope: ZaiUsageScope? = nil,
teamContext: ZaiBigModelTeamContext? = nil,
environment: [String: String] = ProcessInfo.processInfo.environment,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZaiModelUsageData
{
guard !apiKey.isEmpty else {
throw ZaiUsageError.invalidCredentials
}
try ZaiSettingsReader.validateAPIHostEndpointOverride(environment: environment)
let resolvedScope = usageScope ?? .personal
let resolvedTeamContext = try self.resolvedTeamContext(
usageScope: resolvedScope,
explicit: teamContext,
environment: environment)
let baseURL: URL = if let host = ZaiSettingsReader.apiHost(environment: environment),
let resolved = Self.modelUsageURL(baseURLString: host)
{
resolved
} else {
region.modelUsageURL
}
let now = Date()
let calendar = Calendar.current
guard let startDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now)) else {
throw ZaiUsageError.parseFailed("Invalid date calculation")
}
let startComponents = calendar.dateComponents([.year, .month, .day, .hour], from: startDate)
let endComponents = calendar.dateComponents([.year, .month, .day, .hour], from: now)
let startTime = String(
format: "%04d-%02d-%02d %02d:00:00",
startComponents.year!,
startComponents.month!,
startComponents.day!,
startComponents.hour!)
let endTime = String(
format: "%04d-%02d-%02d %02d:59:59",
endComponents.year!,
endComponents.month!,
endComponents.day!,
endComponents.hour!)
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
throw ZaiUsageError.networkError("Invalid URL")
}
components.queryItems = [
URLQueryItem(name: "startTime", value: startTime),
URLQueryItem(name: "endTime", value: endTime),
]
if resolvedScope == .team {
components.queryItems?.append(URLQueryItem(name: "type", value: "3"))
}
guard let requestURL = components.url else {
throw ZaiUsageError.networkError("Invalid URL")
}
var request = URLRequest(url: requestURL)
request.httpMethod = "GET"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
self.applyTeamHeaders(resolvedTeamContext, to: &request)
let response = try await transport.response(for: request)
let data = response.data
guard response.statusCode == 200 else {
let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
Self.log.error("z.ai model-usage API returned \(response.statusCode): \(errorMessage)")
throw ZaiUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)")
}
guard !data.isEmpty else { return ZaiModelUsageData(xTime: [], modelDataList: []) }
return try Self.parseModelUsage(from: data)
}
static func parseModelUsage(from data: Data) throws -> ZaiModelUsageData {
let decoder = JSONDecoder()
let apiResponse = try decoder.decode(ZaiModelUsageAPIResponse.self, from: data)
guard apiResponse.isSuccess else {
throw ZaiUsageError.apiError(apiResponse.msg)
}
guard let responseData = apiResponse.data else {
return ZaiModelUsageData(xTime: [], modelDataList: [])
}
let items = responseData.modelDataList?.map { raw in
ZaiModelDataItem(
modelName: raw.modelName,
tokensUsage: raw.tokensUsage ?? [])
} ?? []
return ZaiModelUsageData(
xTime: responseData.xTime ?? [],
modelDataList: items)
}
/// Fetches required quota data and attaches optional model usage when available.
public static func fetchUsageWithModelUsage(
apiKey: String,
region: ZaiAPIRegion = .global,
usageScope: ZaiUsageScope? = nil,
teamContext: ZaiBigModelTeamContext? = nil,
environment: [String: String] = ProcessInfo.processInfo.environment,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZaiUsageSnapshot
{
try ZaiSettingsReader.validateEndpointOverrides(environment: environment)
let snapshot = try await Self.fetchUsage(
apiKey: apiKey,
region: region,
usageScope: usageScope,
teamContext: teamContext,
environment: environment,
transport: transport)
let modelUsage: ZaiModelUsageData?
do {
modelUsage = try await Self.fetchModelUsage(
apiKey: apiKey,
region: region,
usageScope: usageScope,
teamContext: teamContext,
environment: environment,
transport: transport)
} catch {
Self.log.info("z.ai model usage fetch failed (non-fatal): \(error.localizedDescription)")
modelUsage = nil
}
guard modelUsage != nil else { return snapshot }
return ZaiUsageSnapshot(
tokenLimit: snapshot.tokenLimit,
sessionTokenLimit: snapshot.sessionTokenLimit,
timeLimit: snapshot.timeLimit,
planName: snapshot.planName,
modelUsage: modelUsage,
updatedAt: snapshot.updatedAt)
}
private static func modelUsageURL(baseURLString: String) -> URL? {
guard let cleaned = ZaiSettingsReader.cleaned(baseURLString) else { return nil }
let path = "api/monitor/usage/model-usage"
guard let url = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) else { return nil }
if url.path.isEmpty || url.path == "/" {
return url.appendingPathComponent(path)
}
return url
}
}
// MARK: - Model Usage API Response (private)
private struct ZaiModelUsageAPIResponse: Decodable {
let code: Int
let msg: String
let data: ZaiModelUsageRawData?
let success: Bool
var isSuccess: Bool {
self.success && self.code == 200
}
}
private struct ZaiModelUsageRawData: Decodable {
let xTime: [String]?
let modelDataList: [ZaiModelDataItemRaw]?
enum CodingKeys: String, CodingKey {
case xTime = "x_time"
case modelDataList
}
}
private struct ZaiModelDataItemRaw: Decodable {
let modelName: String?
let tokensUsage: [Int?]?
}
/// Errors that can occur during z.ai usage fetching
public enum ZaiUsageError: LocalizedError, Sendable {
case invalidCredentials
case missingTeamContext
case networkError(String)
case apiError(String)
case parseFailed(String)
public var errorDescription: String? {
switch self {
case .invalidCredentials:
"Invalid z.ai API credentials"
case .missingTeamContext:
"z.ai BigModel team usage requires both Organization ID and Project ID."
case let .networkError(message):
"z.ai network error: \(message)"
case let .apiError(message):
"z.ai API error: \(message)"
case let .parseFailed(message):
"Failed to parse z.ai response: \(message)"
}
}
}