diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift index ee46bc4..724dd99 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift @@ -122,11 +122,19 @@ struct DetailedUsage { let secondaryUsage: Double? let secondaryReset: Date? let primaryReset: Date? + let codexPrimaryWindowLabel: String? + let codexPrimaryWindowHours: Int? + let codexSecondaryWindowLabel: String? + let codexSecondaryWindowHours: Int? let sparkUsage: Double? let sparkReset: Date? let sparkSecondaryUsage: Double? let sparkSecondaryReset: Date? let sparkWindowLabel: String? + let sparkPrimaryWindowLabel: String? + let sparkPrimaryWindowHours: Int? + let sparkSecondaryWindowLabel: String? + let sparkSecondaryWindowHours: Int? // Codex/Antigravity plan info let creditsBalance: Double? @@ -212,11 +220,19 @@ struct DetailedUsage { secondaryUsage: Double? = nil, secondaryReset: Date? = nil, primaryReset: Date? = nil, + codexPrimaryWindowLabel: String? = nil, + codexPrimaryWindowHours: Int? = nil, + codexSecondaryWindowLabel: String? = nil, + codexSecondaryWindowHours: Int? = nil, sparkUsage: Double? = nil, sparkReset: Date? = nil, sparkSecondaryUsage: Double? = nil, sparkSecondaryReset: Date? = nil, sparkWindowLabel: String? = nil, + sparkPrimaryWindowLabel: String? = nil, + sparkPrimaryWindowHours: Int? = nil, + sparkSecondaryWindowLabel: String? = nil, + sparkSecondaryWindowHours: Int? = nil, creditsBalance: Double? = nil, planType: String? = nil, chutesMonthlyValueCapUSD: Double? = nil, @@ -278,11 +294,19 @@ struct DetailedUsage { self.secondaryUsage = secondaryUsage self.secondaryReset = secondaryReset self.primaryReset = primaryReset + self.codexPrimaryWindowLabel = codexPrimaryWindowLabel + self.codexPrimaryWindowHours = codexPrimaryWindowHours + self.codexSecondaryWindowLabel = codexSecondaryWindowLabel + self.codexSecondaryWindowHours = codexSecondaryWindowHours self.sparkUsage = sparkUsage self.sparkReset = sparkReset self.sparkSecondaryUsage = sparkSecondaryUsage self.sparkSecondaryReset = sparkSecondaryReset self.sparkWindowLabel = sparkWindowLabel + self.sparkPrimaryWindowLabel = sparkPrimaryWindowLabel + self.sparkPrimaryWindowHours = sparkPrimaryWindowHours + self.sparkSecondaryWindowLabel = sparkSecondaryWindowLabel + self.sparkSecondaryWindowHours = sparkSecondaryWindowHours self.creditsBalance = creditsBalance self.planType = planType self.chutesMonthlyValueCapUSD = chutesMonthlyValueCapUSD @@ -332,7 +356,9 @@ extension DetailedUsage: Codable { case fiveHourUsage, fiveHourReset, sevenDayUsage, sevenDayReset case sonnetUsage, sonnetReset, opusUsage, opusReset, modelBreakdown, modelResetTimes case secondaryUsage, secondaryReset, primaryReset + case codexPrimaryWindowLabel, codexPrimaryWindowHours, codexSecondaryWindowLabel, codexSecondaryWindowHours case sparkUsage, sparkReset, sparkSecondaryUsage, sparkSecondaryReset, sparkWindowLabel + case sparkPrimaryWindowLabel, sparkPrimaryWindowHours, sparkSecondaryWindowLabel, sparkSecondaryWindowHours case creditsBalance, planType case chutesMonthlyValueCapUSD, chutesMonthlyValueUsedUSD, chutesMonthlyValueUsedPercent case extraUsageEnabled @@ -370,11 +396,19 @@ extension DetailedUsage: Codable { secondaryUsage = try container.decodeIfPresent(Double.self, forKey: .secondaryUsage) secondaryReset = try container.decodeIfPresent(Date.self, forKey: .secondaryReset) primaryReset = try container.decodeIfPresent(Date.self, forKey: .primaryReset) + codexPrimaryWindowLabel = try container.decodeIfPresent(String.self, forKey: .codexPrimaryWindowLabel) + codexPrimaryWindowHours = try container.decodeIfPresent(Int.self, forKey: .codexPrimaryWindowHours) + codexSecondaryWindowLabel = try container.decodeIfPresent(String.self, forKey: .codexSecondaryWindowLabel) + codexSecondaryWindowHours = try container.decodeIfPresent(Int.self, forKey: .codexSecondaryWindowHours) sparkUsage = try container.decodeIfPresent(Double.self, forKey: .sparkUsage) sparkReset = try container.decodeIfPresent(Date.self, forKey: .sparkReset) sparkSecondaryUsage = try container.decodeIfPresent(Double.self, forKey: .sparkSecondaryUsage) sparkSecondaryReset = try container.decodeIfPresent(Date.self, forKey: .sparkSecondaryReset) sparkWindowLabel = try container.decodeIfPresent(String.self, forKey: .sparkWindowLabel) + sparkPrimaryWindowLabel = try container.decodeIfPresent(String.self, forKey: .sparkPrimaryWindowLabel) + sparkPrimaryWindowHours = try container.decodeIfPresent(Int.self, forKey: .sparkPrimaryWindowHours) + sparkSecondaryWindowLabel = try container.decodeIfPresent(String.self, forKey: .sparkSecondaryWindowLabel) + sparkSecondaryWindowHours = try container.decodeIfPresent(Int.self, forKey: .sparkSecondaryWindowHours) creditsBalance = try container.decodeIfPresent(Double.self, forKey: .creditsBalance) planType = try container.decodeIfPresent(String.self, forKey: .planType) chutesMonthlyValueCapUSD = try container.decodeIfPresent(Double.self, forKey: .chutesMonthlyValueCapUSD) @@ -439,11 +473,19 @@ extension DetailedUsage: Codable { try container.encodeIfPresent(secondaryUsage, forKey: .secondaryUsage) try container.encodeIfPresent(secondaryReset, forKey: .secondaryReset) try container.encodeIfPresent(primaryReset, forKey: .primaryReset) + try container.encodeIfPresent(codexPrimaryWindowLabel, forKey: .codexPrimaryWindowLabel) + try container.encodeIfPresent(codexPrimaryWindowHours, forKey: .codexPrimaryWindowHours) + try container.encodeIfPresent(codexSecondaryWindowLabel, forKey: .codexSecondaryWindowLabel) + try container.encodeIfPresent(codexSecondaryWindowHours, forKey: .codexSecondaryWindowHours) try container.encodeIfPresent(sparkUsage, forKey: .sparkUsage) try container.encodeIfPresent(sparkReset, forKey: .sparkReset) try container.encodeIfPresent(sparkSecondaryUsage, forKey: .sparkSecondaryUsage) try container.encodeIfPresent(sparkSecondaryReset, forKey: .sparkSecondaryReset) try container.encodeIfPresent(sparkWindowLabel, forKey: .sparkWindowLabel) + try container.encodeIfPresent(sparkPrimaryWindowLabel, forKey: .sparkPrimaryWindowLabel) + try container.encodeIfPresent(sparkPrimaryWindowHours, forKey: .sparkPrimaryWindowHours) + try container.encodeIfPresent(sparkSecondaryWindowLabel, forKey: .sparkSecondaryWindowLabel) + try container.encodeIfPresent(sparkSecondaryWindowHours, forKey: .sparkSecondaryWindowHours) try container.encodeIfPresent(creditsBalance, forKey: .creditsBalance) try container.encodeIfPresent(planType, forKey: .planType) try container.encodeIfPresent(chutesMonthlyValueCapUSD, forKey: .chutesMonthlyValueCapUSD) diff --git a/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift index 10ed124..b3c44cf 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift @@ -7,6 +7,14 @@ final class CodexProvider: ProviderProtocol { let identifier: ProviderIdentifier = .codex let type: ProviderType = .quotaBased + struct DecodedUsagePayload { + let usage: ProviderUsage + let details: DetailedUsage + } + + // Intentionally internal for @testable unit coverage of endpoint routing + // and payload decoding across standard and self-service Codex responses. + private struct RateLimitWindow: Codable { let used_percent: Double let limit_window_seconds: Int? @@ -178,6 +186,119 @@ final class CodexProvider: ProviderProtocol { let credits: CreditsInfo? } + private struct SelfServiceUsageResponse: Decodable { + let requestCount: Int? + let totalTokens: Int? + let cachedInputTokens: Int? + let totalCostUSD: Double? + let limits: [SelfServiceLimit] + + enum CodingKeys: String, CodingKey { + case requestCount = "request_count" + case totalTokens = "total_tokens" + case cachedInputTokens = "cached_input_tokens" + case totalCostUSD = "total_cost_usd" + case limits + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + requestCount = try container.decodeIfPresent(Int.self, forKey: .requestCount) + totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) + cachedInputTokens = try container.decodeIfPresent(Int.self, forKey: .cachedInputTokens) + totalCostUSD = try container.decodeIfPresent(Double.self, forKey: .totalCostUSD) + limits = (try? container.decodeIfPresent([SelfServiceLimit].self, forKey: .limits)) ?? [] + } + } + + private struct SelfServiceLimit: Decodable { + let limitType: String? + let limitWindow: String? + let maxValue: Double? + let currentValue: Double? + let remainingValue: Double? + let modelFilter: String? + let resetAt: Date? + + enum CodingKeys: String, CodingKey { + case limitType = "limit_type" + case limitWindow = "limit_window" + case maxValue = "max_value" + case currentValue = "current_value" + case remainingValue = "remaining_value" + case modelFilter = "model_filter" + case resetAt = "reset_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + limitType = try container.decodeIfPresent(String.self, forKey: .limitType) + limitWindow = try container.decodeIfPresent(String.self, forKey: .limitWindow) + maxValue = Self.decodeFlexibleDouble(from: container, forKey: .maxValue) + currentValue = Self.decodeFlexibleDouble(from: container, forKey: .currentValue) + remainingValue = Self.decodeFlexibleDouble(from: container, forKey: .remainingValue) + modelFilter = try container.decodeIfPresent(String.self, forKey: .modelFilter) + resetAt = Self.decodeFlexibleDate(from: container, forKey: .resetAt) + } + + private static func decodeFlexibleDouble(from container: KeyedDecodingContainer, forKey key: CodingKeys) -> Double? { + if let value = try? container.decode(Double.self, forKey: key) { + return value + } + if let value = try? container.decode(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decode(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + private static func decodeFlexibleDate(from container: KeyedDecodingContainer, forKey key: CodingKeys) -> Date? { + if let value = try? container.decode(Double.self, forKey: key) { + return value > 2_000_000_000_000 + ? Date(timeIntervalSince1970: value / 1000.0) + : Date(timeIntervalSince1970: value) + } + if let value = try? container.decode(Int.self, forKey: key) { + return value > 2_000_000_000_000 + ? Date(timeIntervalSince1970: TimeInterval(value) / 1000.0) + : Date(timeIntervalSince1970: TimeInterval(value)) + } + if let value = try? container.decode(String.self, forKey: key) { + return Self.parseFlexibleDateString(value) + } + return nil + } + + private static func parseFlexibleDateString(_ value: String) -> Date? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let timestamp = Double(trimmed) { + return timestamp > 2_000_000_000_000 + ? Date(timeIntervalSince1970: timestamp / 1000.0) + : Date(timeIntervalSince1970: timestamp) + } + + let formatterWithFractional = ISO8601DateFormatter() + formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatterWithFractional.date(from: trimmed) { + return date + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: trimmed) + } + } + + private struct ResolvedUsageWindow { + let label: String + let windowHours: Int? + let usagePercent: Double + let resetDate: Date? + } + func fetch() async throws -> ProviderResult { let accounts = TokenManager.shared.getOpenAIAccounts() @@ -303,24 +424,21 @@ final class CodexProvider: ProviderProtocol { private func fetchUsageForAccount(_ account: OpenAIAuthAccount) async throws -> CodexAccountCandidate { let endpointConfiguration = TokenManager.shared.getCodexEndpointConfiguration() - let url = try codexUsageURL(for: endpointConfiguration) + let url = try codexUsageURL(for: endpointConfiguration, account: account) var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(account.accessToken)", forHTTPHeaderField: "Authorization") let requestAccountId = codexRequestAccountID(for: account, endpointMode: endpointConfiguration.mode) + let usesSelfServiceEndpoint = usesSelfServiceUsageEndpoint(account: account, endpointConfiguration: endpointConfiguration) if let accountId = requestAccountId, !accountId.isEmpty { request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id") - } else { + } else if !usesSelfServiceEndpoint { logger.warning( "Codex account ID missing for \(account.authSource, privacy: .public) using endpoint source \(endpointConfiguration.source, privacy: .public); sending request without account header" ) } - logger.debug( - "Codex endpoint resolved: url=\(url.absoluteString, privacy: .public), source=\(endpointConfiguration.source, privacy: .public), external_mode=\(self.isExternalEndpointMode(endpointConfiguration.mode) ? "YES" : "NO"), account_header=\(requestAccountId != nil ? "YES" : "NO")" - ) - let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { @@ -333,10 +451,35 @@ final class CodexProvider: ProviderProtocol { throw ProviderError.networkError("HTTP \(httpResponse.statusCode)") } + let decodedPayload = try decodeUsagePayload( + data: data, + account: account, + endpointConfiguration: endpointConfiguration + ) + return CodexAccountCandidate( + accountId: account.accountId, + usage: decodedPayload.usage, + details: decodedPayload.details, + sourceLabels: account.sourceLabels.isEmpty ? [sourceLabel(account.source)] : account.sourceLabels, + source: account.source + ) + } + + func decodeUsagePayload( + data: Data, + account: OpenAIAuthAccount, + endpointConfiguration: CodexEndpointConfiguration + ) throws -> DecodedUsagePayload { let decoder = JSONDecoder() - let codexResponse: CodexResponse + do { - codexResponse = try decoder.decode(CodexResponse.self, from: data) + if usesSelfServiceUsageEndpoint(account: account, endpointConfiguration: endpointConfiguration) { + let response = try decoder.decode(SelfServiceUsageResponse.self, from: data) + return buildSelfServicePayload(response: response, account: account) + } + + let response = try decoder.decode(CodexResponse.self, from: data) + return buildStandardPayload(response: response, account: account) } catch { logger.error("Failed to decode Codex API response: \(error.localizedDescription)") if let jsonString = String(data: data, encoding: .utf8) { @@ -353,13 +496,119 @@ final class CodexProvider: ProviderProtocol { } throw ProviderError.decodingError(error.localizedDescription) } + } + + func codexUsageURL(for configuration: CodexEndpointConfiguration) throws -> URL { + switch configuration.mode { + case .directChatGPT: + guard let url = URL(string: "https://chatgpt.com/backend-api/wham/usage") else { + logger.error("Default Codex usage URL is invalid; aborting request") + throw ProviderError.providerError("Default Codex usage URL is invalid") + } + return url + case .external(let usageURL): + return usageURL + } + } - guard let baseWindows = codexResponse.rate_limit.resolvedWindows(excludingSpark: true) else { - logger.error("Codex response missing usable rate-limit window") - throw ProviderError.decodingError("Missing rate-limit window") + func codexUsageURL(for configuration: CodexEndpointConfiguration, account: OpenAIAuthAccount) throws -> URL { + if account.credentialType == .apiKey { + switch configuration.mode { + case .directChatGPT: + throw ProviderError.authenticationFailed("Codex API key requires an external codex-lb endpoint") + case .external(let usageURL): + guard var components = URLComponents(url: usageURL, resolvingAgainstBaseURL: false) else { + throw ProviderError.providerError("External Codex usage URL is invalid") + } + let currentPath = components.path + if currentPath.hasSuffix("/api/codex/usage") { + components.path = String(currentPath.dropLast("/api/codex/usage".count)) + "/v1/usage" + } else if currentPath.hasSuffix("/v1/usage") { + // Already points at the self-service endpoint; use as-is. + components.path = currentPath + } else if currentPath.hasSuffix("/usage") { + components.path = String(currentPath.dropLast("/usage".count)) + "/v1/usage" + } else { + let trimmedPath = currentPath.hasSuffix("/") ? String(currentPath.dropLast()) : currentPath + components.path = trimmedPath + "/v1/usage" + } + guard let selfServiceURL = components.url else { + throw ProviderError.providerError("Self-service Codex usage URL is invalid") + } + return selfServiceURL + } + } + + return try codexUsageURL(for: configuration) + } + + func codexRequestAccountID(for account: OpenAIAuthAccount, endpointMode: CodexEndpointMode) -> String? { + if account.credentialType == .apiKey { + return nil + } + switch endpointMode { + case .directChatGPT: + return account.accountId + case .external: + if account.source == .codexLB { + return account.externalUsageAccountId ?? account.accountId + } + return account.accountId } - let primaryWindow = baseWindows.shortWindow - let secondaryWindow = baseWindows.longWindow + } + + func usesSelfServiceUsageEndpoint(account: OpenAIAuthAccount, endpointConfiguration: CodexEndpointConfiguration) -> Bool { + account.credentialType == .apiKey && isExternalEndpointMode(endpointConfiguration.mode) + } + + func isExternalEndpointMode(_ mode: CodexEndpointMode) -> Bool { + if case .external = mode { + return true + } + return false + } + + private func isSameUsage(_ lhs: CodexAccountCandidate, _ rhs: CodexAccountCandidate) -> Bool { + let primaryMatch = sameUsageValue(lhs.details.dailyUsage, rhs.details.dailyUsage) + let secondaryMatch = sameUsageValue(lhs.details.secondaryUsage, rhs.details.secondaryUsage) + let primaryResetMatch = sameDate(lhs.details.primaryReset, rhs.details.primaryReset) + let secondaryResetMatch = sameDate(lhs.details.secondaryReset, rhs.details.secondaryReset) + let primaryLabelMatch = lhs.details.codexPrimaryWindowLabel == rhs.details.codexPrimaryWindowLabel + let secondaryLabelMatch = lhs.details.codexSecondaryWindowLabel == rhs.details.codexSecondaryWindowLabel + let primaryHoursMatch = lhs.details.codexPrimaryWindowHours == rhs.details.codexPrimaryWindowHours + let secondaryHoursMatch = lhs.details.codexSecondaryWindowHours == rhs.details.codexSecondaryWindowHours + let sparkUsageMatch = sameUsageValue(lhs.details.sparkUsage, rhs.details.sparkUsage) + let sparkResetMatch = sameDate(lhs.details.sparkReset, rhs.details.sparkReset) + let sparkSecondaryUsageMatch = sameUsageValue(lhs.details.sparkSecondaryUsage, rhs.details.sparkSecondaryUsage) + let sparkSecondaryResetMatch = sameDate(lhs.details.sparkSecondaryReset, rhs.details.sparkSecondaryReset) + let sparkWindowLabelMatch = lhs.details.sparkWindowLabel == rhs.details.sparkWindowLabel + let sparkPrimaryLabelMatch = lhs.details.sparkPrimaryWindowLabel == rhs.details.sparkPrimaryWindowLabel + let sparkSecondaryLabelMatch = lhs.details.sparkSecondaryWindowLabel == rhs.details.sparkSecondaryWindowLabel + let sparkPrimaryHoursMatch = lhs.details.sparkPrimaryWindowHours == rhs.details.sparkPrimaryWindowHours + let sparkSecondaryHoursMatch = lhs.details.sparkSecondaryWindowHours == rhs.details.sparkSecondaryWindowHours + return primaryMatch + && secondaryMatch + && primaryResetMatch + && secondaryResetMatch + && primaryLabelMatch + && secondaryLabelMatch + && primaryHoursMatch + && secondaryHoursMatch + && sparkUsageMatch + && sparkResetMatch + && sparkSecondaryUsageMatch + && sparkSecondaryResetMatch + && sparkWindowLabelMatch + && sparkPrimaryLabelMatch + && sparkSecondaryLabelMatch + && sparkPrimaryHoursMatch + && sparkSecondaryHoursMatch + } + + private func buildStandardPayload(response codexResponse: CodexResponse, account: OpenAIAuthAccount) -> DecodedUsagePayload { + let baseWindows = codexResponse.rate_limit.resolvedWindows(excludingSpark: true) + let primaryWindow = baseWindows?.shortWindow ?? codexResponse.rate_limit.primaryWindow ?? RateLimitWindow(used_percent: 0, limit_window_seconds: nil, reset_after_seconds: nil, reset_at: nil) + let secondaryWindow = baseWindows?.longWindow let additionalSparkLimit = codexResponse.additional_rate_limits?.first { limit in let name = limit.limit_name ?? "" return name.range(of: "spark", options: .caseInsensitive) != nil @@ -384,8 +633,18 @@ final class CodexProvider: ProviderProtocol { ?? additionalSparkWindows.flatMap { resolveResetDate(now: now, window: $0.shortWindow) } let sparkSecondaryResetDate = inlineSparkSecondary.flatMap { resolveResetDate(now: now, window: $0.1) } ?? (inlineSparkPrimary == nil ? additionalSparkWindows?.longWindow.flatMap { resolveResetDate(now: now, window: $0) } : nil) - - let remaining = Int(100 - primaryUsedPercent) + let primaryWindowMetadata = codexWindowMetadata(for: primaryWindow, fallbackLabel: "5h") + let secondaryWindowMetadata = secondaryWindow.flatMap { codexWindowMetadata(for: $0, fallbackLabel: "Weekly") } + let sparkPrimaryWindowMetadata = sparkUsedPercent != nil + ? (inlineSparkPrimary.map { codexWindowMetadata(for: $0.1, fallbackLabel: "5h") } + ?? additionalSparkWindows.map { codexWindowMetadata(for: $0.shortWindow, fallbackLabel: "5h") }) + : nil + let sparkSecondaryWindowMetadata = sparkSecondaryUsedPercent != nil + ? (inlineSparkSecondary.map { codexWindowMetadata(for: $0.1, fallbackLabel: "Weekly") } + ?? additionalSparkWindows?.longWindow.map { codexWindowMetadata(for: $0, fallbackLabel: "Weekly") }) + : nil + + let remaining = max(0, Int(100 - primaryUsedPercent)) let sourceLabels = account.sourceLabels.isEmpty ? [sourceLabel(account.source)] : account.sourceLabels let authUsageSummary = sourceSummary(sourceLabels, fallback: "Unknown") let details = DetailedUsage( @@ -393,11 +652,19 @@ final class CodexProvider: ProviderProtocol { secondaryUsage: secondaryUsedPercent, secondaryReset: secondaryResetDate, primaryReset: primaryResetDate, + codexPrimaryWindowLabel: primaryWindowMetadata.label, + codexPrimaryWindowHours: primaryWindowMetadata.hours, + codexSecondaryWindowLabel: secondaryWindowMetadata?.label, + codexSecondaryWindowHours: secondaryWindowMetadata?.hours, sparkUsage: sparkUsedPercent, sparkReset: sparkResetDate, sparkSecondaryUsage: sparkSecondaryUsedPercent, sparkSecondaryReset: sparkSecondaryResetDate, sparkWindowLabel: sparkWindowLabel, + sparkPrimaryWindowLabel: sparkPrimaryWindowMetadata?.label, + sparkPrimaryWindowHours: sparkPrimaryWindowMetadata?.hours, + sparkSecondaryWindowLabel: sparkSecondaryWindowMetadata?.label, + sparkSecondaryWindowHours: sparkSecondaryWindowMetadata?.hours, creditsBalance: codexResponse.credits?.balanceAsDouble, planType: codexResponse.plan_type, email: account.email, @@ -420,9 +687,9 @@ final class CodexProvider: ProviderProtocol { """ Codex usage fetched (\(authUsageSummary)): \ email=\(account.email ?? "unknown"), \ - base_short=\(primaryUsedPercent)%(\(baseWindows.shortKey)), \ - base_long=\(secondarySummary)(\(baseWindows.longKey ?? "none")), \ - base_source=\(baseWindows.source), \ + base_short=\(primaryUsedPercent)%(\(baseWindows?.shortKey ?? "primary_window")), \ + base_long=\(secondarySummary)(\(baseWindows?.longKey ?? "none")), \ + base_source=\(baseWindows?.source ?? "fallback"), \ spark_primary=\(sparkSummary), \ spark_secondary=\(sparkWeeklySummary), \ spark_source=\(sparkSource), \ @@ -431,67 +698,211 @@ final class CodexProvider: ProviderProtocol { """ ) - let usage = ProviderUsage.quotaBased(remaining: remaining, entitlement: 100, overagePermitted: false) - return CodexAccountCandidate( - accountId: account.accountId, - usage: usage, - details: details, - sourceLabels: sourceLabels, - source: account.source + return DecodedUsagePayload( + usage: ProviderUsage.quotaBased(remaining: remaining, entitlement: 100, overagePermitted: false), + details: details ) } - func codexUsageURL(for configuration: CodexEndpointConfiguration) throws -> URL { - switch configuration.mode { - case .directChatGPT: - guard let url = URL(string: "https://chatgpt.com/backend-api/wham/usage") else { - logger.error("Default Codex usage URL is invalid; aborting request") - throw ProviderError.providerError("Default Codex usage URL is invalid") - } - return url - case .external(let usageURL): - return usageURL + private func buildSelfServicePayload(response: SelfServiceUsageResponse, account: OpenAIAuthAccount) -> DecodedUsagePayload { + let sourceLabels = account.sourceLabels.isEmpty ? [sourceLabel(account.source)] : account.sourceLabels + let authUsageSummary = sourceSummary(sourceLabels, fallback: "Unknown") + + let grouped = partitionSelfServiceLimits(response.limits) + let primary = resolveUsageWindow(from: grouped.base.first) + let secondary = grouped.base.count > 1 ? resolveUsageWindow(from: grouped.base.last) : nil + let sparkPrimary = resolveUsageWindow(from: grouped.spark.first) + let sparkSecondary = grouped.spark.count > 1 ? resolveUsageWindow(from: grouped.spark.last) : nil + let sparkLabel = normalizeSparkWindowLabel(grouped.spark.first?.modelFilter ?? grouped.spark.first?.limitType) + + let primaryPercent = primary?.usagePercent ?? 0 + let remaining = max(0, Int(100 - primaryPercent)) + let details = DetailedUsage( + dailyUsage: primary?.usagePercent, + secondaryUsage: secondary?.usagePercent, + secondaryReset: secondary?.resetDate, + primaryReset: primary?.resetDate, + codexPrimaryWindowLabel: primary?.label, + codexPrimaryWindowHours: primary?.windowHours, + codexSecondaryWindowLabel: secondary?.label, + codexSecondaryWindowHours: secondary?.windowHours, + sparkUsage: sparkPrimary?.usagePercent, + sparkReset: sparkPrimary?.resetDate, + sparkSecondaryUsage: sparkSecondary?.usagePercent, + sparkSecondaryReset: sparkSecondary?.resetDate, + sparkWindowLabel: sparkLabel, + sparkPrimaryWindowLabel: sparkPrimary?.label, + sparkPrimaryWindowHours: sparkPrimary?.windowHours, + sparkSecondaryWindowLabel: sparkSecondary?.label, + sparkSecondaryWindowHours: sparkSecondary?.windowHours, + email: account.email, + monthlyCost: response.totalCostUSD, + authSource: account.authSource, + authUsageSummary: authUsageSummary + ) + + logger.debug( + """ + Codex self-service usage fetched (\(authUsageSummary)): \ + email=\(account.email ?? "unknown"), \ + base_primary=\(primary.map { String(format: "%.1f%%(%@)", $0.usagePercent, $0.label) } ?? "none"), \ + base_secondary=\(secondary.map { String(format: "%.1f%%(%@)", $0.usagePercent, $0.label) } ?? "none"), \ + spark_primary=\(sparkPrimary.map { String(format: "%.1f%%(%@)", $0.usagePercent, $0.label) } ?? "none"), \ + spark_secondary=\(sparkSecondary.map { String(format: "%.1f%%(%@)", $0.usagePercent, $0.label) } ?? "none"), \ + total_cost_usd=\(response.totalCostUSD.map { String(format: "%.2f", $0) } ?? "none") + """ + ) + + return DecodedUsagePayload( + usage: ProviderUsage.quotaBased(remaining: remaining, entitlement: 100, overagePermitted: false), + details: details + ) + } + + private func partitionSelfServiceLimits(_ limits: [SelfServiceLimit]) -> (base: [SelfServiceLimit], spark: [SelfServiceLimit]) { + let usable = limits.filter { limit in + guard let maxValue = limit.maxValue, maxValue > 0 else { return false } + return limit.currentValue != nil || limit.remainingValue != nil } + + let spark = usable + .filter(isSparkLimit) + .sorted(by: compareSelfServiceLimits) + let base = usable + .filter { !isSparkLimit($0) } + .sorted(by: compareSelfServiceLimits) + return (base: base, spark: spark) } - func codexRequestAccountID(for account: OpenAIAuthAccount, endpointMode: CodexEndpointMode) -> String? { - switch endpointMode { - case .directChatGPT: - return account.accountId - case .external: - if account.source == .codexLB { - return account.externalUsageAccountId ?? account.accountId + private func compareSelfServiceLimits(_ lhs: SelfServiceLimit, _ rhs: SelfServiceLimit) -> Bool { + let lhsHours = normalizedWindowHours(from: lhs.limitWindow) + let rhsHours = normalizedWindowHours(from: rhs.limitWindow) + if let lhsHours, let rhsHours, lhsHours != rhsHours { + return lhsHours < rhsHours + } + if lhs.limitWindow != rhs.limitWindow { + return (lhs.limitWindow ?? "").localizedStandardCompare(rhs.limitWindow ?? "") == .orderedAscending + } + return (lhs.modelFilter ?? lhs.limitType ?? "").localizedStandardCompare(rhs.modelFilter ?? rhs.limitType ?? "") == .orderedAscending + } + + private func isSparkLimit(_ limit: SelfServiceLimit) -> Bool { + let haystack = [limit.modelFilter, limit.limitType] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + return haystack.contains("spark") + } + + private func resolveUsageWindow(from limit: SelfServiceLimit?) -> ResolvedUsageWindow? { + guard let limit, + let maxValue = limit.maxValue, + maxValue > 0 else { + return nil + } + + let currentValue: Double + if let explicitCurrent = limit.currentValue { + currentValue = explicitCurrent + } else if let remainingValue = limit.remainingValue { + currentValue = max(0, maxValue - remainingValue) + } else { + return nil + } + + let rawPercent = (currentValue / maxValue) * 100.0 + let usagePercent = min(max(rawPercent, 0), 100) + return ResolvedUsageWindow( + label: formatCodexWindowLabel(limit.limitWindow), + windowHours: normalizedWindowHours(from: limit.limitWindow), + usagePercent: usagePercent, + resetDate: limit.resetAt + ) + } + + private func formatCodexWindowLabel(_ rawLabel: String?) -> String { + let normalized = rawLabel? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + guard !normalized.isEmpty else { return "Usage" } + + if normalized == "weekly" || normalized == "7d" || normalized == "7day" || normalized == "7days" { + return "Weekly" + } + if normalized == "monthly" || normalized == "30d" || normalized == "30days" || normalized == "31d" || normalized == "31days" { + return "Monthly" + } + if normalized == "daily" || normalized == "1d" || normalized == "1day" || normalized == "1days" || normalized == "24h" { + return "Daily" + } + + if let hours = normalizedWindowHours(from: rawLabel), hours > 0 { + if hours % 24 == 0 { + let days = hours / 24 + if days == 7 { return "Weekly" } + if days >= 28 { return "Monthly" } + if days == 1 { return "Daily" } + return "\(days)d" } - return account.accountId + return "\(hours)h" } + + return rawLabel? + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .capitalized ?? "Usage" } - func isExternalEndpointMode(_ mode: CodexEndpointMode) -> Bool { - if case .external = mode { - return true + private func normalizedWindowHours(from rawLabel: String?) -> Int? { + guard let rawLabel else { return nil } + let normalized = rawLabel.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return nil } + if normalized == "weekly" { return 24 * 7 } + if normalized == "monthly" { return 24 * 30 } + if normalized == "daily" { return 24 } + + let compact = normalized.replacingOccurrences(of: " ", with: "") + let pattern = #"^(\d+)([hdw])$"# + guard let match = compact.range(of: pattern, options: .regularExpression) else { + return nil + } + let matched = String(compact[match]) + let unit = matched.last + let valueString = String(matched.dropLast()) + guard let value = Int(valueString), value > 0 else { return nil } + switch unit { + case "h": return value + case "d": return value * 24 + case "w": return value * 24 * 7 + default: return nil } - return false } - private func isSameUsage(_ lhs: CodexAccountCandidate, _ rhs: CodexAccountCandidate) -> Bool { - let primaryMatch = lhs.details.dailyUsage == rhs.details.dailyUsage - let secondaryMatch = lhs.details.secondaryUsage == rhs.details.secondaryUsage - let primaryResetMatch = sameDate(lhs.details.primaryReset, rhs.details.primaryReset) - let secondaryResetMatch = sameDate(lhs.details.secondaryReset, rhs.details.secondaryReset) - let sparkUsageMatch = lhs.details.sparkUsage == rhs.details.sparkUsage - let sparkResetMatch = sameDate(lhs.details.sparkReset, rhs.details.sparkReset) - let sparkSecondaryUsageMatch = lhs.details.sparkSecondaryUsage == rhs.details.sparkSecondaryUsage - let sparkSecondaryResetMatch = sameDate(lhs.details.sparkSecondaryReset, rhs.details.sparkSecondaryReset) - let sparkWindowLabelMatch = lhs.details.sparkWindowLabel == rhs.details.sparkWindowLabel - return primaryMatch - && secondaryMatch - && primaryResetMatch - && secondaryResetMatch - && sparkUsageMatch - && sparkResetMatch - && sparkSecondaryUsageMatch - && sparkSecondaryResetMatch - && sparkWindowLabelMatch + private func codexWindowMetadata(for window: RateLimitWindow, fallbackLabel: String) -> (label: String, hours: Int?) { + if let seconds = window.limit_window_seconds, + seconds > 0 { + let rawLabel = compactWindowLabel(from: seconds) + return ( + label: formatCodexWindowLabel(rawLabel), + hours: normalizedWindowHours(from: rawLabel) + ) + } + + return ( + label: formatCodexWindowLabel(fallbackLabel), + hours: normalizedWindowHours(from: fallbackLabel) + ) + } + + private func compactWindowLabel(from seconds: Int) -> String { + guard seconds > 0 else { return "Usage" } + if seconds % 604_800 == 0 { + return "\(seconds / 604_800)w" + } + if seconds % 86_400 == 0 { + return "\(seconds / 86_400)d" + } + let roundedHours = max(1, Int(round(Double(seconds) / 3600.0))) + return "\(roundedHours)h" } private func normalizeSparkWindowLabel(_ rawLabel: String?) -> String? { @@ -531,6 +942,17 @@ final class CodexProvider: ProviderProtocol { return false } } + + private func sameUsageValue(_ lhs: Double?, _ rhs: Double?, tolerance: Double = 0.0001) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (left?, right?): + return abs(left - right) <= tolerance + default: + return false + } + } } private enum AnyCodable: Codable { diff --git a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift index 0c4146f..3a48ca1 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift @@ -147,6 +147,13 @@ final class OpenCodeZenProvider: ProviderProtocol { let modelCosts: [String: Double] } + struct DisplayStatsAdjustment { + let totalCost: Double + let avgCostPerDay: Double + let modelCosts: [String: Double] + let excludedCost: Double + } + func fetch() async throws -> ProviderResult { guard let binaryPath = opencodePath else { logger.error("OpenCode CLI not found in PATH or standard locations") @@ -161,22 +168,34 @@ final class OpenCodeZenProvider: ProviderProtocol { debugLog("Fetching current stats only (history tracking disabled)") let output = try await runOpenCodeStats(days: 7) let stats = try parseStats(output) + let endpointConfiguration = TokenManager.shared.getCodexEndpointConfiguration() + let displayStats = Self.adjustStatsForDisplay( + totalCost: stats.totalCost, + avgCostPerDay: stats.avgCostPerDay, + modelCosts: stats.modelCosts, + codexEndpointConfiguration: endpointConfiguration + ) let monthlyLimit = 1000.0 - let utilization = min((stats.totalCost / monthlyLimit) * 100, 100) - logger.info("OpenCode Zen: $\(String(format: "%.2f", stats.totalCost)) (\(String(format: "%.1f", utilization))% of $\(monthlyLimit) limit)") + let utilization = min((displayStats.totalCost / monthlyLimit) * 100, 100) + logger.info("OpenCode Zen: $\(String(format: "%.2f", displayStats.totalCost)) (\(String(format: "%.1f", utilization))% of $\(monthlyLimit) limit)") + if displayStats.excludedCost > 0 { + let excludedSummary = String(format: "%.2f", displayStats.excludedCost) + logger.info("OpenCode Zen: Excluded $\(excludedSummary) of externally routed OpenAI usage from pay-as-you-go totals") + debugLog("Excluded $\(excludedSummary) of externally routed OpenAI usage from OpenCode Zen totals") + } let details = DetailedUsage( - modelBreakdown: stats.modelCosts, + modelBreakdown: displayStats.modelCosts, sessions: stats.sessions > 0 ? stats.sessions : nil, messages: stats.messages > 0 ? stats.messages : nil, - avgCostPerDay: stats.avgCostPerDay > 0 ? stats.avgCostPerDay : nil, - monthlyCost: stats.totalCost, + avgCostPerDay: displayStats.avgCostPerDay > 0 ? displayStats.avgCostPerDay : nil, + monthlyCost: displayStats.totalCost, authSource: "opencode CLI via \(binarySourceDescription)" ) return ProviderResult( - usage: .payAsYouGo(utilization: utilization, cost: stats.totalCost, resetsAt: nil), + usage: .payAsYouGo(utilization: utilization, cost: displayStats.totalCost, resetsAt: nil), details: details ) } @@ -189,7 +208,9 @@ final class OpenCodeZenProvider: ProviderProtocol { return try await withCheckedThrowingContinuation { continuation in let process = Process() process.executableURL = binaryPath - process.arguments = ["stats", "--days", "\(days)", "--models", "10"] + // Use the unlimited --models form so filtering can inspect every + // reported openai/* model instead of truncating the stats table. + process.arguments = ["stats", "--days", "\(days)", "--models"] let pipe = Pipe() process.standardOutput = pipe @@ -234,6 +255,60 @@ final class OpenCodeZenProvider: ProviderProtocol { } } + static func adjustStatsForDisplay( + totalCost: Double, + avgCostPerDay: Double, + modelCosts: [String: Double], + codexEndpointConfiguration: CodexEndpointConfiguration + ) -> DisplayStatsAdjustment { + guard codexEndpointConfiguration.usesOpenAIProviderBaseURL, + case .external = codexEndpointConfiguration.mode else { + return DisplayStatsAdjustment( + totalCost: totalCost, + avgCostPerDay: avgCostPerDay, + modelCosts: modelCosts, + excludedCost: 0 + ) + } + + let excludedCost = modelCosts + .filter { isOpenAIModelRoutedThroughCodex($0.key) } + .reduce(0.0) { partialResult, item in + partialResult + max(item.value, 0) + } + + guard excludedCost > 0 else { + return DisplayStatsAdjustment( + totalCost: totalCost, + avgCostPerDay: avgCostPerDay, + modelCosts: modelCosts, + excludedCost: 0 + ) + } + + let adjustedTotalCost = max(0, totalCost - excludedCost) + let adjustedAvgCostPerDay: Double + if totalCost > 0, avgCostPerDay > 0 { + adjustedAvgCostPerDay = max(0, avgCostPerDay * (adjustedTotalCost / totalCost)) + } else { + adjustedAvgCostPerDay = 0 + } + + let adjustedModelCosts = modelCosts.filter { !isOpenAIModelRoutedThroughCodex($0.key) } + + return DisplayStatsAdjustment( + totalCost: adjustedTotalCost, + avgCostPerDay: adjustedAvgCostPerDay, + modelCosts: adjustedModelCosts, + excludedCost: excludedCost + ) + } + + static func isOpenAIModelRoutedThroughCodex(_ modelName: String) -> Bool { + let normalized = modelName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized.hasPrefix("openai/") + } + /// Parses opencode stats output using regex patterns. private func parseStats(_ output: String) throws -> OpenCodeStats { let totalCostPattern = #"│Total Cost\s+\$([0-9.]+)"# diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index 94635e7..7a4dd4d 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -169,6 +169,11 @@ struct OpenCodeAuth: Codable { let anthropic: OAuth? let openai: OAuth? + /// When `openai` in auth.json is not a valid OAuth object, it may be stored + /// as an API key object (e.g. `{"type":"apiKey","key":"sk-..."}`). This field + /// captures that alternative representation so CodexProvider can still send + /// `Authorization: Bearer ` without requiring OAuth or ~/.codex/auth.json. + let openaiAPIKey: APIKey? let githubCopilot: OAuth? let openrouter: APIKey? let opencode: APIKey? @@ -191,6 +196,7 @@ struct OpenCodeAuth: Codable { init( anthropic: OAuth?, openai: OAuth?, + openaiAPIKey: APIKey?, githubCopilot: OAuth?, openrouter: APIKey?, opencode: APIKey?, @@ -203,6 +209,7 @@ struct OpenCodeAuth: Codable { ) { self.anthropic = anthropic self.openai = openai + self.openaiAPIKey = openaiAPIKey self.githubCopilot = githubCopilot self.openrouter = openrouter self.opencode = opencode @@ -218,6 +225,10 @@ struct OpenCodeAuth: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) anthropic = Self.decodeLossyIfPresent(OAuth.self, from: container, forKey: .anthropic) openai = Self.decodeLossyIfPresent(OAuth.self, from: container, forKey: .openai) + // When openai is not a valid OAuth object, try decoding it as an API key. + openaiAPIKey = (openai == nil) + ? Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .openai) + : nil githubCopilot = Self.decodeLossyIfPresent(OAuth.self, from: container, forKey: .githubCopilot) openrouter = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .openrouter) opencode = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .opencode) @@ -230,6 +241,7 @@ struct OpenCodeAuth: Codable { if anthropic == nil, openai == nil, + openaiAPIKey == nil, githubCopilot == nil, openrouter == nil, opencode == nil, @@ -264,6 +276,8 @@ struct OpenCodeAuth: Codable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(anthropic, forKey: .anthropic) try container.encodeIfPresent(openai, forKey: .openai) + // Encode openaiAPIKey only when OAuth openai is nil to avoid duplicate key + if openai == nil { try container.encodeIfPresent(openaiAPIKey, forKey: .openai) } try container.encodeIfPresent(githubCopilot, forKey: .githubCopilot) try container.encodeIfPresent(openrouter, forKey: .openrouter) try container.encodeIfPresent(opencode, forKey: .opencode) @@ -324,6 +338,11 @@ enum OpenAIAuthSource { case codexAuth } +enum OpenAICredentialType { + case oauthBearer + case apiKey +} + /// Unified OpenAI account model used by the provider layer struct OpenAIAuthAccount { let accessToken: String @@ -333,6 +352,7 @@ struct OpenAIAuthAccount { let authSource: String let sourceLabels: [String] let source: OpenAIAuthSource + let credentialType: OpenAICredentialType } enum CodexEndpointMode: Equatable { @@ -343,6 +363,7 @@ enum CodexEndpointMode: Equatable { struct CodexEndpointConfiguration: Equatable { let mode: CodexEndpointMode let source: String + let usesOpenAIProviderBaseURL: Bool } /// Auth source types for Claude account discovery @@ -705,13 +726,28 @@ final class TokenManager: @unchecked Sendable { ) } - /// Possible opencode.json locations in priority order: - /// 1. $XDG_CONFIG_HOME/opencode/opencode.json (if XDG_CONFIG_HOME is set) - /// 2. ~/.config/opencode/opencode.json (XDG default on macOS/Linux) - /// 3. ~/.local/share/opencode/opencode.json (fallback) - /// 4. ~/Library/Application Support/opencode/opencode.json (macOS fallback) + /// Possible opencode.json/opencode.jsonc locations in priority order. + /// For each directory, opencode.jsonc is preferred over opencode.json + /// (matching copilothydra behavior): + /// 1. $XDG_CONFIG_HOME/opencode/opencode.jsonc (if XDG_CONFIG_HOME is set) + /// 2. $XDG_CONFIG_HOME/opencode/opencode.json (if XDG_CONFIG_HOME is set) + /// 3. ~/.config/opencode/opencode.jsonc (XDG default on macOS/Linux) + /// 4. ~/.config/opencode/opencode.json (XDG default on macOS/Linux) + /// 5. ~/.local/share/opencode/opencode.jsonc (fallback) + /// 6. ~/.local/share/opencode/opencode.json (fallback) + /// 7. ~/Library/Application Support/opencode/opencode.jsonc (macOS fallback) + /// 8. ~/Library/Application Support/opencode/opencode.json (macOS fallback) func getOpenCodeConfigFilePaths() -> [URL] { - return buildOpenCodeFilePaths( + let jsoncPaths = buildOpenCodeFilePaths( + envVarName: "XDG_CONFIG_HOME", + envRelativePathComponents: ["opencode", "opencode.jsonc"], + fallbackRelativePathComponents: [ + [".config", "opencode", "opencode.jsonc"], + [".local", "share", "opencode", "opencode.jsonc"], + ["Library", "Application Support", "opencode", "opencode.jsonc"] + ] + ) + let jsonPaths = buildOpenCodeFilePaths( envVarName: "XDG_CONFIG_HOME", envRelativePathComponents: ["opencode", "opencode.json"], fallbackRelativePathComponents: [ @@ -720,6 +756,13 @@ final class TokenManager: @unchecked Sendable { ["Library", "Application Support", "opencode", "opencode.json"] ] ) + + assert( + jsoncPaths.count == jsonPaths.count, + "OpenCode jsonc/json path arrays must remain equal length for correct interleaving" + ) + + return zip(jsoncPaths, jsonPaths).flatMap { [$0, $1] } } /// Possible search-keys.json locations in priority order: @@ -810,7 +853,7 @@ final class TokenManager: @unchecked Sendable { } } - private func stripJSONComments(from data: Data) -> Data { + func stripJSONComments(from data: Data) -> Data { guard let text = String(data: data, encoding: .utf8) else { return data } @@ -927,7 +970,8 @@ final class TokenManager: @unchecked Sendable { ) -> CodexEndpointConfiguration { let defaultConfiguration = CodexEndpointConfiguration( mode: .directChatGPT, - source: "Default ChatGPT usage endpoint" + source: "Default ChatGPT usage endpoint", + usesOpenAIProviderBaseURL: false ) guard let configDictionary else { @@ -943,7 +987,8 @@ final class TokenManager: @unchecked Sendable { resolvedURL.host != nil { return CodexEndpointConfiguration( mode: .external(usageURL: resolvedURL), - source: sourcePath ?? "opencode-bar.codex.usageURL" + source: sourcePath ?? "opencode-bar.codex.usageURL", + usesOpenAIProviderBaseURL: false ) } @@ -968,7 +1013,8 @@ final class TokenManager: @unchecked Sendable { if let usageURL = components.url { return CodexEndpointConfiguration( mode: .external(usageURL: usageURL), - source: sourcePath ?? "provider.openai.options.baseURL" + source: sourcePath ?? "provider.openai.options.baseURL", + usesOpenAIProviderBaseURL: true ) } } @@ -1073,6 +1119,14 @@ final class TokenManager: @unchecked Sendable { } } + func clearOpenCodeAuthCacheForTesting() { + queue.sync { + cachedAuth = nil + cacheTimestamp = nil + lastFoundAuthPath = nil + } + } + // MARK: - Codex Native Auth File Reading private var cachedCodexAuth: CodexAuth? @@ -1508,7 +1562,8 @@ final class TokenManager: @unchecked Sendable { email: normalizedEmail?.isEmpty == true ? nil : normalizedEmail, authSource: authSourcePath, sourceLabels: [openAISourceLabel(for: .codexLB)], - source: .codexLB + source: .codexLB, + credentialType: .oauthBearer ) } @@ -1745,7 +1800,8 @@ final class TokenManager: @unchecked Sendable { email: (primaryEmail?.isEmpty == false) ? primaryEmail : fallbackEmail, authSource: primary.authSource, sourceLabels: mergedSourceLabels, - source: primary.source + source: primary.source, + credentialType: primary.credentialType ) } @@ -2792,7 +2848,26 @@ final class TokenManager: @unchecked Sendable { email: nil, authSource: authSource, sourceLabels: [openAISourceLabel(for: .opencodeAuth)], - source: .opencodeAuth + source: .opencodeAuth, + credentialType: .oauthBearer + ) + ) + } + + if let auth = readOpenCodeAuth(), + let apiKey = auth.openaiAPIKey, + !apiKey.key.isEmpty { + let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + accounts.append( + OpenAIAuthAccount( + accessToken: apiKey.key, + accountId: nil, + externalUsageAccountId: nil, + email: nil, + authSource: authSource, + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey ) ) } @@ -2820,7 +2895,31 @@ final class TokenManager: @unchecked Sendable { email: codexEmail, authSource: authSource, sourceLabels: [openAISourceLabel(for: .codexAuth)], - source: .codexAuth + source: .codexAuth, + credentialType: .oauthBearer + ) + ) + } + + if let codexAuth = readCodexAuth(), + codexAuth.tokens?.accessToken?.isEmpty != false, + let apiKey = codexAuth.openaiAPIKey?.trimmingCharacters(in: .whitespacesAndNewlines), + !apiKey.isEmpty { + let homeDir = FileManager.default.homeDirectoryForCurrentUser + let authSource = homeDir + .appendingPathComponent(".codex") + .appendingPathComponent("auth.json") + .path + accounts.append( + OpenAIAuthAccount( + accessToken: apiKey, + accountId: nil, + externalUsageAccountId: nil, + email: nil, + authSource: authSource, + sourceLabels: ["Codex (API Key)"], + source: .codexAuth, + credentialType: .apiKey ) ) } @@ -3018,11 +3117,18 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let access = auth.openai?.access { return access } + if let auth = readOpenCodeAuth(), let apiKey = auth.openaiAPIKey?.key { + return apiKey + } // Fallback: Codex CLI native auth (~/.codex/auth.json) if let codexAuth = readCodexAuth(), let access = codexAuth.tokens?.accessToken { logger.info("Using Codex native auth (~/.codex/auth.json) as fallback for OpenAI access token") return access } + if let codexAuth = readCodexAuth(), let apiKey = codexAuth.openaiAPIKey?.trimmingCharacters(in: .whitespacesAndNewlines), !apiKey.isEmpty { + logger.info("Using Codex native auth API key (~/.codex/auth.json) as fallback for OpenAI access token") + return apiKey + } return nil } @@ -3699,7 +3805,12 @@ final class TokenManager: @unchecked Sendable { } lines.append("[ChatGPT]") - lines.append(" OpenCode auth.json (\(shortPath(openCodePath))): \(tokenStatus(hasAuth: openCodeAuth != nil, token: openCodeAuth?.openai?.access, accountId: openCodeAuth?.openai?.accountId))") + let openAIStatus = tokenStatus( + hasAuth: openCodeAuth?.openai != nil || openCodeAuth?.openaiAPIKey != nil, + token: openCodeAuth?.openai?.access ?? openCodeAuth?.openaiAPIKey?.key, + accountId: openCodeAuth?.openai?.accountId + ) + lines.append(" OpenCode auth.json (\(shortPath(openCodePath))): \(openAIStatus)") let codexAuthPath = homeDir.appendingPathComponent(".codex").appendingPathComponent("auth.json") if fileManager.fileExists(atPath: codexAuthPath.path) { @@ -3932,7 +4043,7 @@ final class TokenManager: @unchecked Sendable { debugLines.append("Token Status:") if let auth = readOpenCodeAuth() { debugLines.append(" [Anthropic] \(auth.anthropic != nil ? "CONFIGURED" : "NOT CONFIGURED")") - debugLines.append(" [OpenAI] \(auth.openai != nil ? "CONFIGURED" : "NOT CONFIGURED")") + debugLines.append(" [OpenAI] \((auth.openai != nil || auth.openaiAPIKey != nil) ? "CONFIGURED" : "NOT CONFIGURED")") debugLines.append(gitHubCopilotTokenStatusLine()) debugLines.append(" [OpenRouter] \(auth.openrouter != nil ? "CONFIGURED" : "NOT CONFIGURED")") debugLines.append(" [OpenCode] \(auth.opencode != nil ? "CONFIGURED" : "NOT CONFIGURED")") @@ -4190,6 +4301,10 @@ final class TokenManager: @unchecked Sendable { let expiresDate = Date(timeIntervalSince1970: TimeInterval(openai.expires)) let isExpired = expiresDate < Date() debugLines.append(" - Expires: \(expiresDate) (\(isExpired ? "EXPIRED" : "valid"))") + } else if let openaiAPIKey = auth.openaiAPIKey { + debugLines.append("[OpenAI] API Key Present") + debugLines.append(" - Key Length: \(openaiAPIKey.key.count) chars") + debugLines.append(" - Key Preview: \(maskToken(openaiAPIKey.key))") } else { debugLines.append("[OpenAI] NOT CONFIGURED") } diff --git a/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift index 02e6e01..591e51c 100644 --- a/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift @@ -187,6 +187,322 @@ final class CodexProviderTests: XCTestCase { XCTAssertEqual(auth.openaiAPIKey, "sk-only-key") } + func testCodexUsageURLUsesSelfServiceEndpointForExternalAPIKey() throws { + let account = OpenAIAuthAccount( + accessToken: "sk-clb-test", + accountId: nil, + externalUsageAccountId: nil, + email: nil, + authSource: "auth.json", + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey + ) + let configuration = CodexEndpointConfiguration( + mode: .external(usageURL: URL(string: "https://codex.example.com/api/codex/usage")!), + source: "test", + usesOpenAIProviderBaseURL: true + ) + + let url = try provider.codexUsageURL(for: configuration, account: account) + + XCTAssertEqual(url.absoluteString, "https://codex.example.com/v1/usage") + } + + func testCodexUsageURLPreservesURLPrefixForSelfServiceEndpoint() throws { + let account = OpenAIAuthAccount( + accessToken: "sk-clb-test", + accountId: nil, + externalUsageAccountId: nil, + email: nil, + authSource: "auth.json", + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey + ) + let configuration = CodexEndpointConfiguration( + mode: .external(usageURL: URL(string: "https://codex.example.com/proxy/api/codex/usage")!), + source: "test", + usesOpenAIProviderBaseURL: false + ) + + let url = try provider.codexUsageURL(for: configuration, account: account) + + XCTAssertEqual(url.absoluteString, "https://codex.example.com/proxy/v1/usage") + } + + func testCodexUsageURLDoesNotDoubleInjectV1ForAlreadyVersionedPath() throws { + let account = OpenAIAuthAccount( + accessToken: "sk-clb-test", + accountId: nil, + externalUsageAccountId: nil, + email: nil, + authSource: "auth.json", + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey + ) + // URL whose path ends in /v1/usage but has an extra prefix segment. + // The old code would strip just "/usage" and append "/v1/usage", producing + // the incorrect "/api/v1/v1/usage". The fix preserves the path as-is because + // it already terminates with /v1/usage. + let configuration = CodexEndpointConfiguration( + mode: .external(usageURL: URL(string: "https://codex.example.com/api/v1/usage")!), + source: "test", + usesOpenAIProviderBaseURL: true + ) + + let url = try provider.codexUsageURL(for: configuration, account: account) + + XCTAssertEqual(url.absoluteString, "https://codex.example.com/api/v1/usage") + XCTAssertFalse(url.path.contains("/v1/v1"), "Path must not contain a double /v1 injection") + } + + func testCodexUsageURLPreservesAlreadySelfServiceEndpoint() throws { + let account = OpenAIAuthAccount( + accessToken: "sk-clb-test", + accountId: nil, + externalUsageAccountId: nil, + email: nil, + authSource: "auth.json", + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey + ) + let configuration = CodexEndpointConfiguration( + mode: .external(usageURL: URL(string: "https://codex.example.com/v1/usage")!), + source: "test", + usesOpenAIProviderBaseURL: true + ) + + let url = try provider.codexUsageURL(for: configuration, account: account) + + XCTAssertEqual(url.absoluteString, "https://codex.example.com/v1/usage") + } + + func testCodexUsageURLRejectsDirectChatGPTModeForAPIKey() { + let account = OpenAIAuthAccount( + accessToken: "sk-clb-test", + accountId: nil, + externalUsageAccountId: nil, + email: nil, + authSource: "auth.json", + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey + ) + let configuration = CodexEndpointConfiguration( + mode: .directChatGPT, + source: "test", + usesOpenAIProviderBaseURL: false + ) + + XCTAssertThrowsError(try provider.codexUsageURL(for: configuration, account: account)) { error in + guard case let ProviderError.authenticationFailed(message) = error else { + return XCTFail("Expected authenticationFailed, got \(error)") + } + XCTAssertEqual(message, "Codex API key requires an external codex-lb endpoint") + } + } + + func testCodexRequestAccountIDOmittedForAPIKeySelfService() { + let account = OpenAIAuthAccount( + accessToken: "sk-clb-test", + accountId: "should-not-be-used", + externalUsageAccountId: "chatgpt-account-id", + email: nil, + authSource: "auth.json", + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey + ) + + let accountID = provider.codexRequestAccountID( + for: account, + endpointMode: .external(usageURL: URL(string: "https://codex.example.com/api/codex/usage")!) + ) + + XCTAssertNil(accountID) + } + + func testDecodeUsagePayloadMapsSelfServiceLimitsToCodexWindows() throws { + let json = """ + { + "request_count": 321, + "total_tokens": 654321, + "cached_input_tokens": 12345, + "total_cost_usd": 11.75, + "limits": [ + { + "limit_type": "requests", + "limit_window": "5h", + "max_value": 200, + "current_value": 50, + "remaining_value": 150, + "model_filter": null, + "reset_at": "2026-04-02T12:00:00Z" + }, + { + "limit_type": "requests", + "limit_window": "7d", + "max_value": 1000, + "current_value": 300, + "remaining_value": 700, + "model_filter": null, + "reset_at": "2026-04-09T12:00:00Z" + }, + { + "limit_type": "requests", + "limit_window": "5h", + "max_value": 400, + "current_value": 40, + "remaining_value": 360, + "model_filter": "gpt-5.3-codex-spark", + "reset_at": "2026-04-02T13:00:00Z" + }, + { + "limit_type": "requests", + "limit_window": "7d", + "max_value": 1400, + "current_value": 140, + "remaining_value": 1260, + "model_filter": "gpt-5.3-codex-spark", + "reset_at": "2026-04-09T13:00:00Z" + } + ] + } + """ + let account = OpenAIAuthAccount( + accessToken: "sk-clb-test", + accountId: nil, + externalUsageAccountId: nil, + email: "user@example.com", + authSource: "auth.json", + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey + ) + let configuration = CodexEndpointConfiguration( + mode: .external(usageURL: URL(string: "https://codex.example.com/api/codex/usage")!), + source: "test", + usesOpenAIProviderBaseURL: true + ) + + let payload = try provider.decodeUsagePayload( + data: XCTUnwrap(json.data(using: .utf8)), + account: account, + endpointConfiguration: configuration + ) + + XCTAssertEqual(payload.usage.usagePercentage, 25.0, accuracy: 0.001) + XCTAssertEqual(payload.details.dailyUsage, 25.0, accuracy: 0.001) + XCTAssertEqual(payload.details.secondaryUsage, 30.0, accuracy: 0.001) + XCTAssertEqual(payload.details.codexPrimaryWindowLabel, "5h") + XCTAssertEqual(payload.details.codexSecondaryWindowLabel, "Weekly") + XCTAssertEqual(payload.details.codexPrimaryWindowHours, 5) + XCTAssertEqual(payload.details.codexSecondaryWindowHours, 168) + XCTAssertEqual(payload.details.sparkUsage, 10.0, accuracy: 0.001) + XCTAssertEqual(payload.details.sparkSecondaryUsage, 10.0, accuracy: 0.001) + XCTAssertEqual(payload.details.sparkWindowLabel, "Gpt 5.3 Codex Spark") + XCTAssertEqual(payload.details.sparkPrimaryWindowLabel, "5h") + XCTAssertEqual(payload.details.sparkSecondaryWindowLabel, "Weekly") + XCTAssertEqual(payload.details.monthlyCost, 11.75, accuracy: 0.001) + } + + func testDecodeUsagePayloadDerivesStandardWindowLabelsFromLimitSeconds() throws { + let json = """ + { + "plan_type": "plus", + "rate_limit": { + "primary_window": { + "used_percent": 20, + "limit_window_seconds": 21600, + "reset_after_seconds": 3600 + }, + "secondary_window": { + "used_percent": 35, + "limit_window_seconds": 1209600, + "reset_after_seconds": 86400 + }, + "spark_primary_window": { + "used_percent": 10, + "limit_window_seconds": 43200, + "reset_after_seconds": 1800 + }, + "spark_secondary_window": { + "used_percent": 12, + "limit_window_seconds": 2419200, + "reset_after_seconds": 7200 + } + } + } + """ + let account = OpenAIAuthAccount( + accessToken: "oauth-token", + accountId: "account-id", + externalUsageAccountId: nil, + email: "user@example.com", + authSource: "auth.json", + sourceLabels: ["Codex Auth"], + source: .codexAuth, + credentialType: .oauthBearer + ) + let configuration = CodexEndpointConfiguration( + mode: .directChatGPT, + source: "test", + usesOpenAIProviderBaseURL: false + ) + + let payload = try provider.decodeUsagePayload( + data: XCTUnwrap(json.data(using: .utf8)), + account: account, + endpointConfiguration: configuration + ) + + XCTAssertEqual(payload.details.codexPrimaryWindowLabel, "6h") + XCTAssertEqual(payload.details.codexPrimaryWindowHours, 6) + XCTAssertEqual(payload.details.codexSecondaryWindowLabel, "14d") + XCTAssertEqual(payload.details.codexSecondaryWindowHours, 336) + XCTAssertEqual(payload.details.sparkPrimaryWindowLabel, "12h") + XCTAssertEqual(payload.details.sparkPrimaryWindowHours, 12) + XCTAssertEqual(payload.details.sparkSecondaryWindowLabel, "28d") + XCTAssertEqual(payload.details.sparkSecondaryWindowHours, 672) + } + + func testDecodeUsagePayloadHandlesMissingLimitsKeyGracefully() throws { + let json = """ + { + "request_count": 100, + "total_cost_usd": 5.0 + } + """ + let account = OpenAIAuthAccount( + accessToken: "sk-clb-test", + accountId: nil, + externalUsageAccountId: nil, + email: "user@example.com", + authSource: "auth.json", + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey + ) + let configuration = CodexEndpointConfiguration( + mode: .external(usageURL: URL(string: "https://codex.example.com/api/codex/usage")!), + source: "test", + usesOpenAIProviderBaseURL: true + ) + + let payload = try provider.decodeUsagePayload( + data: XCTUnwrap(json.data(using: .utf8)), + account: account, + endpointConfiguration: configuration + ) + + // No limits → no used percentage; provider shows 0% used (100 remaining) + XCTAssertEqual(payload.usage.remainingQuota, 100) + XCTAssertEqual(payload.details.monthlyCost, 5.0, accuracy: 0.001) + } + private func loadFixture(named: String) throws -> Any { let testBundle = Bundle(for: type(of: self)) diff --git a/CopilotMonitor/CopilotMonitorTests/OpenCodeAuthDecodingTests.swift b/CopilotMonitor/CopilotMonitorTests/OpenCodeAuthDecodingTests.swift index 546cb5c..f947204 100644 --- a/CopilotMonitor/CopilotMonitorTests/OpenCodeAuthDecodingTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/OpenCodeAuthDecodingTests.swift @@ -22,6 +22,7 @@ final class OpenCodeAuthDecodingTests: XCTestCase { let auth = try JSONDecoder().decode(OpenCodeAuth.self, from: data) XCTAssertNil(auth.openai, "OpenAI entry is not OAuth, so it should be ignored instead of failing decoding") + XCTAssertEqual(auth.openaiAPIKey?.key, "sk-test-openai") XCTAssertEqual(auth.openrouter?.key, "or-test-key") XCTAssertEqual(auth.githubCopilot?.access, "gho_test") } @@ -38,6 +39,20 @@ final class OpenCodeAuthDecodingTests: XCTestCase { XCTAssertEqual(auth.openrouter?.key, "or-raw-string-key") } + func testOpenAIAPIKeyCanDecodeFromStringValue() throws { + let json = """ + { + "openai": "sk-raw-openai-key" + } + """ + + let data = try XCTUnwrap(json.data(using: .utf8)) + let auth = try JSONDecoder().decode(OpenCodeAuth.self, from: data) + + XCTAssertNil(auth.openai) + XCTAssertEqual(auth.openaiAPIKey?.key, "sk-raw-openai-key") + } + func testMiniMaxCodingPlanAPIKeyDecodes() throws { let json = """ { @@ -71,6 +86,7 @@ final class OpenCodeAuthDecodingTests: XCTestCase { let auth = try JSONDecoder().decode(OpenCodeAuth.self, from: data) XCTAssertEqual(auth.openai?.access, "eyJ.test") + XCTAssertNil(auth.openaiAPIKey) XCTAssertEqual(auth.openai?.expires, 1_770_563_557_150) XCTAssertEqual(auth.openai?.accountId, "123") } diff --git a/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift new file mode 100644 index 0000000..642b21b --- /dev/null +++ b/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import OpenCode_Bar + +final class OpenCodeZenProviderTests: XCTestCase { + + func testAdjustStatsForDisplayExcludesOpenAIModelsWhenOpenAIBaseURLRoutesToCodex() { + let configuration = CodexEndpointConfiguration( + mode: .external(usageURL: URL(string: "https://codex.2631.eu/api/codex/usage")!), + source: "/tmp/opencode.json", + usesOpenAIProviderBaseURL: true + ) + + let adjusted = OpenCodeZenProvider.adjustStatsForDisplay( + totalCost: 22.0, + avgCostPerDay: 3.142857, + modelCosts: [ + "openai/gpt-5.4": 11.2679, + "openai/gpt-5.4-mini": 3.7001, + "nano-gpt/minimax/minimax-m2.5": 4.2045, + "nano-gpt/zai-org/glm-5:thinking": 1.6042 + ], + codexEndpointConfiguration: configuration + ) + + XCTAssertEqual(adjusted.excludedCost, 14.968, accuracy: 0.0001) + XCTAssertEqual(adjusted.totalCost, 7.032, accuracy: 0.0001) + XCTAssertEqual(adjusted.avgCostPerDay, 1.004571, accuracy: 0.0001) + XCTAssertEqual(adjusted.modelCosts.keys.sorted(), [ + "nano-gpt/minimax/minimax-m2.5", + "nano-gpt/zai-org/glm-5:thinking" + ]) + } + + func testAdjustStatsForDisplayKeepsOpenAIModelsForExplicitUsageOverride() { + let configuration = CodexEndpointConfiguration( + mode: .external(usageURL: URL(string: "https://custom.example.com/api/codex/usage")!), + source: "/tmp/opencode.json", + usesOpenAIProviderBaseURL: false + ) + + let adjusted = OpenCodeZenProvider.adjustStatsForDisplay( + totalCost: 12.0, + avgCostPerDay: 4.0, + modelCosts: [ + "openai/gpt-5.4": 9.0, + "openrouter/qwen/qwen3": 3.0 + ], + codexEndpointConfiguration: configuration + ) + + XCTAssertEqual(adjusted.excludedCost, 0) + XCTAssertEqual(adjusted.totalCost, 12.0) + XCTAssertEqual(adjusted.avgCostPerDay, 4.0) + XCTAssertEqual(adjusted.modelCosts.count, 2) + } +} diff --git a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift index be7fc69..8d7d27a 100644 --- a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift @@ -8,7 +8,8 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(configuration, CodexEndpointConfiguration( mode: .directChatGPT, - source: "Default ChatGPT usage endpoint" + source: "Default ChatGPT usage endpoint", + usesOpenAIProviderBaseURL: false )) } @@ -28,7 +29,8 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(configuration, CodexEndpointConfiguration( mode: .external(usageURL: URL(string: "https://codex.2631.eu/api/codex/usage")!), - source: "/tmp/opencode.json" + source: "/tmp/opencode.json", + usesOpenAIProviderBaseURL: true )) } @@ -53,7 +55,8 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(configuration, CodexEndpointConfiguration( mode: .external(usageURL: URL(string: "https://custom.example.com/api/codex/usage")!), - source: "/tmp/opencode.json" + source: "/tmp/opencode.json", + usesOpenAIProviderBaseURL: false )) } @@ -78,7 +81,8 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(configuration, CodexEndpointConfiguration( mode: .external(usageURL: URL(string: "https://codex.2631.eu/api/codex/usage")!), - source: "/tmp/opencode.json" + source: "/tmp/opencode.json", + usesOpenAIProviderBaseURL: true )) } @@ -98,10 +102,66 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(configuration, CodexEndpointConfiguration( mode: .directChatGPT, - source: "Default ChatGPT usage endpoint" + source: "Default ChatGPT usage endpoint", + usesOpenAIProviderBaseURL: false )) } + func testGetOpenAIAccountsIncludesOpenCodeAPIKeyAccount() throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let xdgDataHome = tempDirectory.path + let authDirectory = tempDirectory + .appendingPathComponent("opencode", isDirectory: true) + let authPath = authDirectory.appendingPathComponent("auth.json") + + try fileManager.createDirectory(at: authDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempDirectory) } + + let originalXDGDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] + if let originalXDGDataHome { + setenv("XDG_DATA_HOME", originalXDGDataHome, 1) + } else { + unsetenv("XDG_DATA_HOME") + } + defer { + if let originalXDGDataHome { + setenv("XDG_DATA_HOME", originalXDGDataHome, 1) + } else { + unsetenv("XDG_DATA_HOME") + } + TokenManager.shared.clearOpenCodeAuthCacheForTesting() + } + + let json = """ + { + "openai": { + "type": "apiKey", + "key": "sk-openai-api-key" + } + } + """ + try XCTUnwrap(json.data(using: .utf8)).write(to: authPath) + + setenv("XDG_DATA_HOME", xdgDataHome, 1) + TokenManager.shared.clearOpenCodeAuthCacheForTesting() + + let accounts = TokenManager.shared.getOpenAIAccounts() + let apiKeyAccount = try XCTUnwrap( + accounts.first(where: { + $0.accessToken == "sk-openai-api-key" && + $0.authSource == authPath.path && + $0.sourceLabels == ["OpenCode (API Key)"] + }) + ) + + XCTAssertNil(apiKeyAccount.accountId) + XCTAssertNil(apiKeyAccount.externalUsageAccountId) + XCTAssertNil(apiKeyAccount.email) + XCTAssertEqual(apiKeyAccount.source, .opencodeAuth) + } + func testReadClaudeAnthropicAuthFilesIncludesDisabledAccounts() throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory @@ -174,7 +234,8 @@ final class TokenManagerTests: XCTestCase { email: "user@example.com", authSource: "codex-lb", sourceLabels: ["Codex LB"], - source: .codexLB + source: .codexLB, + credentialType: .oauthBearer ) let accountID = provider.codexRequestAccountID( @@ -208,6 +269,7 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(account.externalUsageAccountId, "chatgpt-id") XCTAssertEqual(account.email, "user@example.com") XCTAssertEqual(account.source, .codexLB) + XCTAssertEqual(account.credentialType, .oauthBearer) } func testCodexProviderKeepsDefaultAccountIDInDirectMode() { @@ -219,7 +281,8 @@ final class TokenManagerTests: XCTestCase { email: "user@example.com", authSource: "codex-lb", sourceLabels: ["Codex LB"], - source: .codexLB + source: .codexLB, + credentialType: .oauthBearer ) let accountID = provider.codexRequestAccountID( @@ -239,7 +302,8 @@ final class TokenManagerTests: XCTestCase { email: "user@example.com", authSource: "opencode-auth", sourceLabels: ["OpenCode"], - source: .opencodeAuth + source: .opencodeAuth, + credentialType: .oauthBearer ) let accountID = provider.codexRequestAccountID( @@ -258,9 +322,113 @@ final class TokenManagerTests: XCTestCase { email: nil, authSource: "opencode-auth", sourceLabels: ["OpenCode"], - source: .opencodeAuth + source: .opencodeAuth, + credentialType: .oauthBearer ) XCTAssertNil(account.externalUsageAccountId) } + + // MARK: - opencode.jsonc Precedence Tests + + func testOpenCodeConfigFilePathsReturnsJSONCBeforeJSONForEachLocation() { + let paths = TokenManager.shared.getOpenCodeConfigFilePaths() + let pathStrings = paths.map { $0.path } + + // Each .jsonc path must appear before its corresponding .json path + // for the same directory. Verify by checking every .json path has a + // .jsonc counterpart earlier in the array. + for (index, path) in pathStrings.enumerated() where path.hasSuffix(".json") && !path.hasSuffix(".jsonc") { + let jsoncVariant = path.replacingOccurrences(of: ".json", with: ".jsonc") + if let jsoncIndex = pathStrings.firstIndex(of: jsoncVariant) { + XCTAssertLessThan( + jsoncIndex, + index, + "opencode.jsonc (\(jsoncVariant)) should appear before opencode.json (\(path)) in search order" + ) + } + } + } + + func testOpenCodeConfigFilePathsContainsBothExtensions() { + let paths = TokenManager.shared.getOpenCodeConfigFilePaths() + let pathStrings = paths.map { $0.path } + + let jsoncCount = pathStrings.filter { $0.hasSuffix(".jsonc") }.count + let jsonCount = pathStrings.filter { $0.hasSuffix(".json") && !$0.hasSuffix(".jsonc") }.count + + XCTAssertGreaterThan(jsoncCount, 0, "Expected at least one .jsonc path") + XCTAssertGreaterThan(jsonCount, 0, "Expected at least one .json path") + XCTAssertEqual(jsoncCount, jsonCount, "Expected equal number of .jsonc and .json paths") + } + + func testOpenCodeConfigFilePathsContainsExpectedDirectories() { + let paths = TokenManager.shared.getOpenCodeConfigFilePaths() + let pathStrings = paths.map { $0.path } + + // Verify the three expected config directories are covered for each extension. + // Use hasSuffix instead of contains to avoid .json matching .jsonc paths. + let expectedSuffixes = [ + "/.config/opencode/opencode.jsonc", + "/.config/opencode/opencode.json", + "/.local/share/opencode/opencode.jsonc", + "/.local/share/opencode/opencode.json", + "/Application Support/opencode/opencode.jsonc", + "/Application Support/opencode/opencode.json" + ] + + for suffix in expectedSuffixes { + let matches = pathStrings.filter { $0.hasSuffix(suffix) } + XCTAssertEqual( + matches.count, + 1, + "Expected exactly one path ending with '\(suffix)', found \(matches.count): \(matches)" + ) + } + } + + func testStripJSONCommentsProducesValidJSONFromJSONCInput() throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempDirectory) } + + // Create .jsonc content that includes comments. + let jsoncContent = """ + { + // JSONC-specific comment + "provider": { + "openai": { + "options": { + "baseURL": "https://from-jsonc.example.com/v1" + } + } + } + } + """ + + let jsoncPath = tempDirectory.appendingPathComponent("opencode.jsonc") + try Data(jsoncContent.utf8).write(to: jsoncPath) + + let jsoncData = try Data(contentsOf: jsoncPath) + let normalizedData = TokenManager.shared.stripJSONComments(from: jsoncData) + let jsonObject = try JSONSerialization.jsonObject(with: normalizedData) + let dict = try XCTUnwrap(jsonObject as? [String: Any]) + + let configuration = TokenManager.shared.codexEndpointConfiguration( + from: dict, + sourcePath: jsoncPath.path + ) + + XCTAssertEqual( + configuration, + CodexEndpointConfiguration( + mode: .external(usageURL: URL(string: "https://from-jsonc.example.com/api/codex/usage")!), + source: jsoncPath.path, + usesOpenAIProviderBaseURL: true + ), + "Expected JSONC input to remain valid after stripping comments" + ) + } }