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 ?? "" 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)" } } }