diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53672b2c..f60e1faa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,6 +46,7 @@ jobs: - name: Build (Debug) run: | cd CopilotMonitor + set -o pipefail xcodebuild build \ -project CopilotMonitor.xcodeproj \ -scheme CopilotMonitor \ @@ -54,11 +55,26 @@ jobs: CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ - | xcbeautify --renderer github-actions || true - + | xcbeautify --renderer github-actions + + - name: Test (Debug) + run: | + cd CopilotMonitor + set -o pipefail + xcodebuild test \ + -project CopilotMonitor.xcodeproj \ + -scheme CopilotMonitor \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + | xcbeautify --renderer github-actions + - name: Build (Release) run: | cd CopilotMonitor + set -o pipefail xcodebuild build \ -project CopilotMonitor.xcodeproj \ -scheme CopilotMonitor \ @@ -67,4 +83,4 @@ jobs: CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ - | xcbeautify --renderer github-actions || true + | xcbeautify --renderer github-actions diff --git a/CopilotMonitor/CopilotMonitor/Info.plist b/CopilotMonitor/CopilotMonitor/Info.plist index 8f9124c6..5b582160 100644 --- a/CopilotMonitor/CopilotMonitor/Info.plist +++ b/CopilotMonitor/CopilotMonitor/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.9.0 + 2.9.1 CFBundleVersion - 2.9.0 + 2.9.1 LSUIElement MACOSX_DEPLOYMENT_TARGET diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift index ee46bc45..724dd996 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 10ed1246..a2ad123b 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() @@ -242,6 +363,8 @@ final class CodexProvider: ProviderProtocol { private func sourcePriority(_ source: OpenAIAuthSource) -> Int { switch source { case .opencodeAuth: + return 3 + case .openCodeMultiAuth: return 2 case .codexLB: return 1 @@ -254,6 +377,8 @@ final class CodexProvider: ProviderProtocol { switch source { case .opencodeAuth: return "OpenCode" + case .openCodeMultiAuth: + return "OpenCode Multi Auth" case .codexLB: return "Codex LB" case .codexAuth: @@ -303,24 +428,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 +455,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 +500,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 +637,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 +656,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 +691,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 +702,210 @@ 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" { + 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 == 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 +945,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 0c4146f8..3a48ca18 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 94635e73..ad65f4fe 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -16,10 +16,22 @@ struct OpenCodeAuth: Codable { let refresh: String let expires: Int64 let accountId: String? + let idToken: String? + let accountIdOverride: String? + let organizationIdOverride: String? + let accountIdSource: String? + let accountLabel: String? + let multiAccount: Bool? enum CodingKeys: String, CodingKey { case type, access, refresh, expires case accountId = "accountId" + case idToken + case accountIdOverride + case organizationIdOverride + case accountIdSource + case accountLabel + case multiAccount } init(from decoder: Decoder) throws { @@ -44,6 +56,12 @@ struct OpenCodeAuth: Codable { expires = Self.decodeFlexibleInt64(from: container, forKey: .expires) ?? 0 accountId = Self.decodeFlexibleString(from: container, forKey: .accountId) + idToken = Self.decodeFlexibleString(from: container, forKey: .idToken) + accountIdOverride = Self.decodeFlexibleString(from: container, forKey: .accountIdOverride) + organizationIdOverride = Self.decodeFlexibleString(from: container, forKey: .organizationIdOverride) + accountIdSource = Self.decodeFlexibleString(from: container, forKey: .accountIdSource) + accountLabel = Self.decodeFlexibleString(from: container, forKey: .accountLabel) + multiAccount = Self.decodeFlexibleBool(from: container, forKey: .multiAccount) } private static func decodeFlexibleInt64( @@ -86,6 +104,29 @@ struct OpenCodeAuth: Codable { return nil } + private static func decodeFlexibleBool( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) -> Bool? { + if let value = decodeLossyIfPresent(Bool.self, from: container, forKey: key) { + return value + } + if let value = decodeLossyIfPresent(Int.self, from: container, forKey: key) { + return value != 0 + } + if let value = decodeLossyIfPresent(String.self, from: container, forKey: key) { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "true", "yes", "1": + return true + case "false", "no", "0": + return false + default: + return nil + } + } + return nil + } + private static func decodeLossyIfPresent( _ type: T.Type, from container: KeyedDecodingContainer, @@ -169,6 +210,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 +237,7 @@ struct OpenCodeAuth: Codable { init( anthropic: OAuth?, openai: OAuth?, + openaiAPIKey: APIKey?, githubCopilot: OAuth?, openrouter: APIKey?, opencode: APIKey?, @@ -203,6 +250,7 @@ struct OpenCodeAuth: Codable { ) { self.anthropic = anthropic self.openai = openai + self.openaiAPIKey = openaiAPIKey self.githubCopilot = githubCopilot self.openrouter = openrouter self.opencode = opencode @@ -218,6 +266,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 +282,7 @@ struct OpenCodeAuth: Codable { if anthropic == nil, openai == nil, + openaiAPIKey == nil, githubCopilot == nil, openrouter == nil, opencode == nil, @@ -264,6 +317,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) @@ -320,10 +375,16 @@ struct CodexLBEncryptedAccount { /// Auth source types for OpenAI (Codex) account discovery enum OpenAIAuthSource { case opencodeAuth + case openCodeMultiAuth case codexLB case codexAuth } +enum OpenAICredentialType { + case oauthBearer + case apiKey +} + /// Unified OpenAI account model used by the provider layer struct OpenAIAuthAccount { let accessToken: String @@ -333,6 +394,7 @@ struct OpenAIAuthAccount { let authSource: String let sourceLabels: [String] let source: OpenAIAuthSource + let credentialType: OpenAICredentialType } enum CodexEndpointMode: Equatable { @@ -343,6 +405,7 @@ enum CodexEndpointMode: Equatable { struct CodexEndpointConfiguration: Equatable { let mode: CodexEndpointMode let source: String + let usesOpenAIProviderBaseURL: Bool } /// Auth source types for Claude account discovery @@ -582,6 +645,28 @@ private struct OpenAIIDTokenPayload: Decodable { let email: String? } +private struct OpenAIAccessTokenPayload: Decodable { + struct AuthClaims: Decodable { + let chatGPTAccountId: String? + + enum CodingKeys: String, CodingKey { + case chatGPTAccountId = "chatgpt_account_id" + } + } + + struct ProfileClaims: Decodable { + let email: String? + } + + let auth: AuthClaims? + let profile: ProfileClaims? + + enum CodingKeys: String, CodingKey { + case auth = "https://api.openai.com/auth" + case profile = "https://api.openai.com/profile" + } +} + /// Gemini OAuth token response structure struct GeminiTokenResponse: Codable { let access_token: String @@ -631,6 +716,13 @@ final class TokenManager: @unchecked Sendable { private var cachedClaudeAccounts: [ClaudeAuthAccount]? private var claudeAccountsCacheTimestamp: Date? + /// Cached oc-chatgpt-multi-auth OpenAI accounts + private var cachedOpenCodeMultiAuthAccounts: [OpenAIAuthAccount]? + private var openCodeMultiAuthAccountsCacheTimestamp: Date? + + /// Paths where oc-chatgpt-multi-auth account files were found + private(set) var lastFoundOpenCodeMultiAuthPaths: [URL] = [] + /// Cached GitHub Copilot token accounts (OpenCode + VS Code) private var cachedCopilotAccounts: [CopilotAuthAccount]? private var copilotAccountsCacheTimestamp: Date? @@ -705,13 +797,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 +827,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 +924,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 } @@ -921,13 +1035,28 @@ final class TokenManager: @unchecked Sendable { return current as? String } + private func hasPlugin(named pluginIdentifier: String, in configDictionary: [String: Any]) -> Bool { + guard let plugins = valueForNormalizedKey("plugin", in: configDictionary) as? [Any] else { + return false + } + + for plugin in plugins { + guard let rawPlugin = plugin as? String else { continue } + if rawPlugin.range(of: pluginIdentifier, options: .caseInsensitive) != nil { + return true + } + } + return false + } + func codexEndpointConfiguration( from configDictionary: [String: Any]?, sourcePath: String? = nil ) -> CodexEndpointConfiguration { let defaultConfiguration = CodexEndpointConfiguration( mode: .directChatGPT, - source: "Default ChatGPT usage endpoint" + source: "Default ChatGPT usage endpoint", + usesOpenAIProviderBaseURL: false ) guard let configDictionary else { @@ -943,7 +1072,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 ) } @@ -952,6 +1082,14 @@ final class TokenManager: @unchecked Sendable { ) } + if hasPlugin(named: "oc-chatgpt-multi-auth", in: configDictionary) { + return CodexEndpointConfiguration( + mode: .directChatGPT, + source: "oc-chatgpt-multi-auth direct ChatGPT usage endpoint", + usesOpenAIProviderBaseURL: false + ) + } + if let baseURLString = resolveConfigValue( nestedString(in: configDictionary, path: ["provider", "openai", "options", "baseURL"]) ) { @@ -968,7 +1106,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 +1212,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 +1655,8 @@ final class TokenManager: @unchecked Sendable { email: normalizedEmail?.isEmpty == true ? nil : normalizedEmail, authSource: authSourcePath, sourceLabels: [openAISourceLabel(for: .codexLB)], - source: .codexLB + source: .codexLB, + credentialType: .oauthBearer ) } @@ -1606,6 +1754,8 @@ final class TokenManager: @unchecked Sendable { switch source { case .opencodeAuth: return "OpenCode" + case .openCodeMultiAuth: + return "OpenCode Multi Auth" case .codexLB: return "Codex LB" case .codexAuth: @@ -1653,10 +1803,34 @@ final class TokenManager: @unchecked Sendable { return trimmed.isEmpty ? nil : trimmed } + private struct ResolvedOpenAIAuthMetadata { + let accountId: String? + let overrideAccountId: String? + let email: String? + } + + private func resolvedOpenAIAuthMetadata(from oauth: OpenCodeAuth.OAuth?) -> ResolvedOpenAIAuthMetadata { + let accessTokenPayload = decodeOpenAIAccessTokenPayload(oauth?.access) + let idTokenPayload = decodeOpenAIIDTokenPayload(oauth?.idToken) + let tokenAccountId = normalizedNonEmpty(accessTokenPayload?.auth?.chatGPTAccountId) + let overrideAccountId = normalizedNonEmpty(oauth?.accountIdOverride) + ?? normalizedNonEmpty(oauth?.organizationIdOverride) + ?? normalizedNonEmpty(oauth?.accountId) + let email = normalizedNonEmpty(idTokenPayload?.email) + ?? normalizedNonEmpty(accessTokenPayload?.profile?.email) + + return ResolvedOpenAIAuthMetadata( + accountId: tokenAccountId ?? overrideAccountId, + overrideAccountId: overrideAccountId, + email: email + ) + } + private func dedupeOpenAIAccounts(_ accounts: [OpenAIAuthAccount]) -> [OpenAIAuthAccount] { func priority(for source: OpenAIAuthSource) -> Int { switch source { - case .opencodeAuth: return 2 + case .opencodeAuth: return 3 + case .openCodeMultiAuth: return 2 case .codexLB: return 1 case .codexAuth: return 0 } @@ -1745,7 +1919,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 ) } @@ -1927,6 +2102,12 @@ final class TokenManager: @unchecked Sendable { let email: String? } + private struct OpenAIMultiAuthPayload { + let accessToken: String + let accountId: String? + let email: String? + } + private func valueForNormalizedKey(_ normalizedKeyName: String, in dict: [String: Any]) -> Any? { for (key, value) in dict where normalizedKey(key) == normalizedKeyName { return value @@ -2009,6 +2190,141 @@ final class TokenManager: @unchecked Sendable { return nil } + private func openCodeMultiAuthPaths() -> [URL] { + let fileManager = FileManager.default + let homeDir = fileManager.homeDirectoryForCurrentUser + let openCodeRoot = homeDir.appendingPathComponent(".opencode", isDirectory: true) + + var paths: [URL] = [ + openCodeRoot.appendingPathComponent("auth").appendingPathComponent("openai.json"), + openCodeRoot.appendingPathComponent("openai-codex-accounts.json") + ] + + let projectsDir = openCodeRoot.appendingPathComponent("projects", isDirectory: true) + if let projectDirectories = try? fileManager.contentsOfDirectory( + at: projectsDir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) { + let projectFiles = projectDirectories + .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } + .map { $0.appendingPathComponent("openai-codex-accounts.json") } + paths.append(contentsOf: projectFiles) + } + + var deduped: [URL] = [] + var visited = Set() + for path in paths { + let normalizedPath = path.standardizedFileURL.path + if visited.insert(normalizedPath).inserted { + deduped.append(path) + } + } + return deduped + } + + private func decodeOpenAIAccessTokenPayload(_ accessToken: String?) -> OpenAIAccessTokenPayload? { + guard let token = normalizedNonEmpty(accessToken) else { + return nil + } + + let parts = token.split(separator: ".") + guard parts.count >= 2 else { + return nil + } + + let payload = String(parts[1]) + do { + let data = try decodeBase64URL(payload) + return try JSONDecoder().decode(OpenAIAccessTokenPayload.self, from: data) + } catch { + logger.warning("Failed to decode OpenAI access token payload: \(error.localizedDescription)") + return nil + } + } + + private func extractOpenAIMultiAuthPayload(from dict: [String: Any]) -> OpenAIMultiAuthPayload? { + let accessKeys: Set = ["accesstoken", "access", "oauthtoken", "token"] + let accountKeys: Set = ["accountid", "chatgptaccountid", "userid", "id"] + let emailKeys: Set = ["email", "useremail", "login", "username"] + + guard let accessToken = findDirectStringValue(in: dict, matching: accessKeys) + ?? findStringValue(in: dict, matching: accessKeys) else { + return nil + } + + let accessTokenPayload = decodeOpenAIAccessTokenPayload(accessToken) + let tokenAccountId = normalizedNonEmpty(accessTokenPayload?.auth?.chatGPTAccountId) + let storedAccountId = findDirectStringValue(in: dict, matching: accountKeys) + ?? findStringValue(in: dict, matching: accountKeys) + let email = normalizedNonEmpty(accessTokenPayload?.profile?.email) + ?? normalizedNonEmpty(findDirectStringValue(in: dict, matching: emailKeys) + ?? findStringValue(in: dict, matching: emailKeys)) + + return OpenAIMultiAuthPayload( + accessToken: accessToken, + accountId: tokenAccountId ?? normalizedNonEmpty(storedAccountId), + email: email + ) + } + + func readOpenAIMultiAuthFiles(at paths: [URL]) -> [OpenAIAuthAccount] { + var accounts: [OpenAIAuthAccount] = [] + + for path in paths { + guard let dict = readJSONDictionary(at: path) else { continue } + let rawAccounts = valueForNormalizedKey("accounts", in: dict) as? [Any] ?? [dict] + var pathAccounts: [OpenAIAuthAccount] = [] + + for rawAccount in rawAccounts { + guard let accountDict = rawAccount as? [String: Any], + let payload = extractOpenAIMultiAuthPayload(from: accountDict) else { + continue + } + + pathAccounts.append( + OpenAIAuthAccount( + accessToken: payload.accessToken, + accountId: payload.accountId, + externalUsageAccountId: nil, + email: payload.email, + authSource: path.path, + sourceLabels: [openAISourceLabel(for: .openCodeMultiAuth)], + source: .openCodeMultiAuth, + credentialType: .oauthBearer + ) + ) + } + + if !pathAccounts.isEmpty { + logger.info("Loaded \(pathAccounts.count) OpenAI account(s) from oc-chatgpt-multi-auth at \(path.path)") + accounts.append(contentsOf: pathAccounts) + } + } + + return accounts + } + + private func readOpenAIMultiAuthFiles() -> [OpenAIAuthAccount] { + return queue.sync { + if let cached = cachedOpenCodeMultiAuthAccounts, + let timestamp = openCodeMultiAuthAccountsCacheTimestamp, + Date().timeIntervalSince(timestamp) < cacheValiditySeconds { + return cached + } + + let fileManager = FileManager.default + let paths = openCodeMultiAuthPaths() + let accounts = readOpenAIMultiAuthFiles(at: paths) + let existingPaths = paths.filter { fileManager.fileExists(atPath: $0.path) } + + cachedOpenCodeMultiAuthAccounts = accounts + openCodeMultiAuthAccountsCacheTimestamp = Date() + lastFoundOpenCodeMultiAuthPaths = existingPaths + return accounts + } + } + private func parseJSONDictionary(from data: Data) -> [String: Any]? { guard let json = try? JSONSerialization.jsonObject(with: data, options: []), let dict = json as? [String: Any] else { @@ -2784,19 +3100,44 @@ final class TokenManager: @unchecked Sendable { let access = auth.openai?.access, !access.isEmpty { let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let metadata = resolvedOpenAIAuthMetadata(from: auth.openai) accounts.append( OpenAIAuthAccount( accessToken: access, - accountId: auth.openai?.accountId, + accountId: metadata.accountId, + externalUsageAccountId: metadata.overrideAccountId != metadata.accountId ? metadata.overrideAccountId : nil, + email: metadata.email, + authSource: authSource, + sourceLabels: [openAISourceLabel(for: .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: [openAISourceLabel(for: .opencodeAuth)], - source: .opencodeAuth + sourceLabels: ["OpenCode (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey ) ) } + let openCodeMultiAuthAccounts = readOpenAIMultiAuthFiles() + if !openCodeMultiAuthAccounts.isEmpty { + accounts.append(contentsOf: openCodeMultiAuthAccounts) + } + let codexLBAccounts = readCodexLBOpenAIAccounts() if !codexLBAccounts.isEmpty { accounts.append(contentsOf: codexLBAccounts) @@ -2820,7 +3161,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,18 +3383,26 @@ 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 } /// Gets OpenAI account ID, first from OpenCode auth, then falling back to Codex CLI native auth func getOpenAIAccountId() -> String? { // Primary: OpenCode auth - if let auth = readOpenCodeAuth(), let accountId = auth.openai?.accountId { + if let auth = readOpenCodeAuth(), + let accountId = resolvedOpenAIAuthMetadata(from: auth.openai).accountId { return accountId } // Fallback: Codex CLI native auth (~/.codex/auth.json) @@ -3699,7 +4072,30 @@ 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 openCodeOpenAIMetadata = resolvedOpenAIAuthMetadata(from: openCodeAuth?.openai) + let openAIStatus = tokenStatus( + hasAuth: openCodeAuth?.openai != nil || openCodeAuth?.openaiAPIKey != nil, + token: openCodeAuth?.openai?.access ?? openCodeAuth?.openaiAPIKey?.key, + accountId: openCodeOpenAIMetadata.accountId ?? openCodeOpenAIMetadata.overrideAccountId + ) + lines.append(" OpenCode auth.json (\(shortPath(openCodePath))): \(openAIStatus)") + + let openCodeMultiAuthPaths = openCodeMultiAuthPaths() + let existingOpenCodeMultiAuthPaths = openCodeMultiAuthPaths.filter { fileManager.fileExists(atPath: $0.path) } + if existingOpenCodeMultiAuthPaths.isEmpty { + let defaultOpenCodeAuth = homeDir.appendingPathComponent(".opencode").appendingPathComponent("auth").appendingPathComponent("openai.json") + let defaultOpenCodeAccounts = homeDir.appendingPathComponent(".opencode").appendingPathComponent("openai-codex-accounts.json") + lines.append(" oc-chatgpt-multi-auth auth (\(shortPath(defaultOpenCodeAuth.path))): NOT FOUND") + lines.append(" oc-chatgpt-multi-auth accounts (\(shortPath(defaultOpenCodeAccounts.path))): NOT FOUND") + } else { + for path in existingOpenCodeMultiAuthPaths { + let accountCount = readOpenAIMultiAuthFiles(at: [path]).count + let label = path.lastPathComponent == "openai.json" + ? "oc-chatgpt-multi-auth auth" + : "oc-chatgpt-multi-auth accounts" + lines.append(" \(label) (\(shortPath(path.path))): FOUND (\(accountCount) account(s))") + } + } let codexAuthPath = homeDir.appendingPathComponent(".codex").appendingPathComponent("auth.json") if fileManager.fileExists(atPath: codexAuthPath.path) { @@ -3932,7 +4328,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")") @@ -4184,12 +4580,23 @@ final class TokenManager: @unchecked Sendable { // OpenAI if let openai = auth.openai { + let metadata = resolvedOpenAIAuthMetadata(from: openai) debugLines.append("[OpenAI] OAuth Present") debugLines.append(" - Access Token: \(openai.access.count) chars") debugLines.append(" - Refresh Token: \(openai.refresh.count) chars") let expiresDate = Date(timeIntervalSince1970: TimeInterval(openai.expires)) let isExpired = expiresDate < Date() debugLines.append(" - Expires: \(expiresDate) (\(isExpired ? "EXPIRED" : "valid"))") + debugLines.append(" - Account ID: \(metadata.accountId ?? "nil")") + if let overrideAccountId = metadata.overrideAccountId, + overrideAccountId != metadata.accountId { + debugLines.append(" - Account Override: \(overrideAccountId)") + } + debugLines.append(" - Email: \(metadata.email ?? "nil")") + } 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") } @@ -4310,7 +4717,23 @@ final class TokenManager: @unchecked Sendable { debugLines.append("[Codex Auth] NOT FOUND at \(codexAuthPath.path)") } - // 8. codex-lb auth (~/.codex-lb/store.db + encryption.key) + // 8. oc-chatgpt-multi-auth (~/.opencode/*.json) + debugLines.append("---------- oc-chatgpt-multi-auth ----------") + let openCodeMultiAuthPaths = openCodeMultiAuthPaths() + let openCodeMultiAuthAccounts = readOpenAIMultiAuthFiles() + let existingOpenCodeMultiAuthPaths = openCodeMultiAuthPaths.filter { fileManager.fileExists(atPath: $0.path) } + if existingOpenCodeMultiAuthPaths.isEmpty { + debugLines.append("[oc-chatgpt-multi-auth] auth/openai.json: NOT FOUND") + debugLines.append("[oc-chatgpt-multi-auth] openai-codex-accounts.json: NOT FOUND") + } else { + for path in existingOpenCodeMultiAuthPaths { + let accountCount = readOpenAIMultiAuthFiles(at: [path]).count + debugLines.append("[oc-chatgpt-multi-auth] \(path.path): \(accountCount) account(s)") + } + debugLines.append("[oc-chatgpt-multi-auth] Total parsed accounts: \(openCodeMultiAuthAccounts.count)") + } + + // 9. codex-lb auth (~/.codex-lb/store.db + encryption.key) debugLines.append("---------- codex-lb Auth ----------") let codexLBAccounts = readCodexLBOpenAIAccounts() if let storePath = lastFoundCodexLBStorePath, diff --git a/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift index 02e6e018..1cee3f56 100644 --- a/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift @@ -187,6 +187,400 @@ 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 testDecodeUsagePayloadKeepsThirtyDayWindowAsThirtyDays() throws { + let json = """ + { + "plan_type": "plus", + "rate_limit": { + "secondary_window": { + "used_percent": 35, + "limit_window_seconds": 2592000, + "reset_after_seconds": 86400 + } + } + } + """ + 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.codexSecondaryWindowLabel, "30d") + XCTAssertEqual(payload.details.codexSecondaryWindowHours, 720) + } + + func testDecodeUsagePayloadKeepsTwentyNineDayWindowAsTwentyNineDays() throws { + let json = """ + { + "plan_type": "plus", + "rate_limit": { + "secondary_window": { + "used_percent": 35, + "limit_window_seconds": 2505600, + "reset_after_seconds": 86400 + } + } + } + """ + 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.codexSecondaryWindowLabel, "29d") + XCTAssertEqual(payload.details.codexSecondaryWindowHours, 696) + } + + 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 546cb5ce..f947204a 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 00000000..642b21bd --- /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 be7fc69b..c54fa0ab 100644 --- a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift @@ -3,12 +3,48 @@ import XCTest final class TokenManagerTests: XCTestCase { + private func makeTestJWT(payload: String) -> String { + func encode(_ string: String) -> String { + Data(string.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + let header = #"{"alg":"RS256","typ":"JWT"}"# + return "\(encode(header)).\(encode(payload)).signature" + } + func testCodexEndpointConfigurationDefaultsToChatGPT() { let configuration = TokenManager.shared.codexEndpointConfiguration(from: nil) XCTAssertEqual(configuration, CodexEndpointConfiguration( mode: .directChatGPT, - source: "Default ChatGPT usage endpoint" + source: "Default ChatGPT usage endpoint", + usesOpenAIProviderBaseURL: false + )) + } + + func testCodexEndpointConfigurationPrefersDirectChatGPTForOcChatGPTMultiAuthPlugin() { + let configuration = TokenManager.shared.codexEndpointConfiguration( + from: [ + "plugin": ["oc-chatgpt-multi-auth"], + "provider": [ + "openai": [ + "options": [ + "baseURL": "http://127.0.0.1:2455/v1" + ] + ] + ] + ], + sourcePath: "/tmp/opencode.json" + ) + + XCTAssertEqual(configuration, CodexEndpointConfiguration( + mode: .directChatGPT, + source: "oc-chatgpt-multi-auth direct ChatGPT usage endpoint", + usesOpenAIProviderBaseURL: false )) } @@ -28,7 +64,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 +90,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 +116,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 +137,96 @@ 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 testOpenCodeAuthDecodesOcChatGPTMultiAuthFields() throws { + let json = """ + { + "openai": { + "type": "oauth", + "refresh": "refresh-token", + "access": "access-token", + "expires": 1776088671146, + "idToken": "id-token", + "multiAccount": true, + "accountIdOverride": "org-selected-account", + "organizationIdOverride": "org-selected-account", + "accountIdSource": "org", + "accountLabel": "Personal [id:abc123]" + } + } + """ + + let auth = try JSONDecoder().decode(OpenCodeAuth.self, from: XCTUnwrap(json.data(using: .utf8))) + + XCTAssertEqual(auth.openai?.access, "access-token") + XCTAssertEqual(auth.openai?.refresh, "refresh-token") + XCTAssertEqual(auth.openai?.idToken, "id-token") + XCTAssertEqual(auth.openai?.accountIdOverride, "org-selected-account") + XCTAssertEqual(auth.openai?.organizationIdOverride, "org-selected-account") + XCTAssertEqual(auth.openai?.accountIdSource, "org") + XCTAssertEqual(auth.openai?.accountLabel, "Personal [id:abc123]") + XCTAssertEqual(auth.openai?.multiAccount, true) + } + func testReadClaudeAnthropicAuthFilesIncludesDisabledAccounts() throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory @@ -165,6 +290,64 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(expiresAt.timeIntervalSince1970, 1_770_563_557.15, accuracy: 0.01) } + func testReadOpenAIMultiAuthFilesCanonicalizesAccountIDFromAccessTokenClaims() throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let accountsPath = tempDirectory.appendingPathComponent("openai-codex-accounts.json") + + try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempDirectory) } + + let accessToken = makeTestJWT( + payload: #""" + { + "https://api.openai.com/auth": { + "chatgpt_account_id": "chatgpt-account-id" + }, + "https://api.openai.com/profile": { + "email": "user@example.com" + } + } + """# + ) + + let json = """ + { + "version": 3, + "accounts": [ + { + "accountId": "org-example-account", + "organizationId": "org-example-account", + "accountIdSource": "org", + "accessToken": "\(accessToken)", + "refreshToken": "refresh-1", + "expiresAt": 1770563557150 + }, + { + "accountId": "chatgpt-account-id", + "accountIdSource": "token", + "accessToken": "\(accessToken)", + "refreshToken": "refresh-1", + "expiresAt": 1770563557150 + } + ], + "activeIndex": 0 + } + """ + + try XCTUnwrap(json.data(using: .utf8)).write(to: accountsPath) + + let accounts = TokenManager.shared.readOpenAIMultiAuthFiles(at: [accountsPath]) + + XCTAssertEqual(accounts.count, 2) + XCTAssertEqual(accounts.map(\.accountId), ["chatgpt-account-id", "chatgpt-account-id"]) + XCTAssertEqual(accounts.map(\.email), ["user@example.com", "user@example.com"]) + XCTAssertEqual(accounts.map(\.source), [.openCodeMultiAuth, .openCodeMultiAuth]) + XCTAssertEqual(accounts.map(\.authSource), [accountsPath.path, accountsPath.path]) + XCTAssertEqual(accounts.map(\.sourceLabels), [["OpenCode Multi Auth"], ["OpenCode Multi Auth"]]) + } + func testCodexProviderUsesChatGPTAccountIDForCodexLBInExternalMode() { let provider = CodexProvider() let account = OpenAIAuthAccount( @@ -174,7 +357,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 +392,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 +404,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 +425,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 +445,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" + ) + } } diff --git a/README.md b/README.md index 1666ee35..d005a933 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi | **GitHub Copilot** | Quota-based | Multi-account, daily history, overage tracking, auth source labels | ### OpenCode Plugins +- **ChatGPT / Codex** + - `ndycode/oc-chatgpt-multi-auth` + - Reads `~/.opencode/openai-codex-accounts.json` and `~/.opencode/projects/*/openai-codex-accounts.json` + - Also understands plugin-managed OpenCode `auth.json` fields such as `idToken`, `accountIdOverride`, and `organizationIdOverride` - **Antigravity/Gemini** - `NoeFabris/opencode-antigravity-auth` (writes `~/.config/opencode/antigravity-accounts.json`) - `jenslys/opencode-gemini-auth` (writes `google.oauth` in OpenCode `auth.json`) @@ -75,8 +79,10 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi - **Browser Cookies** - Chrome, Brave, Arc, Edge session cookies - Multiple accounts from different sources are automatically deduplicated and merged - **Codex** + - **OpenCode + oc-chatgpt-multi-auth** - Auto-detected from OpenCode `auth.json` plus `~/.opencode/.../openai-codex-accounts.json` - **Codex for Mac** - Auto-detected through `~/.codex/auth.json` - **Codex CLI** - Auto-detected through `~/.codex/auth.json` + - **codex-lb** - Auto-detected through `~/.codex-lb/` - **Claude Code CLI** - Keychain-based authentication detection ## Features @@ -354,8 +360,8 @@ Quit (⌘Q) ## How It Works -1. **Token Discovery**: Reads authentication tokens from OpenCode's `auth.json` (with multi-path fallback) -2. **Multi-Source Account Discovery**: For providers like GitHub Copilot, discovers accounts from multiple sources (OpenCode auth, CLI Keychain, VS Code config, browser cookies) and deduplicates by login/email +1. **Token Discovery**: Reads authentication tokens from OpenCode's `auth.json` (with multi-path fallback), including plugin-managed OpenAI metadata +2. **Multi-Source Account Discovery**: For providers like ChatGPT and GitHub Copilot, discovers accounts from multiple sources (OpenCode auth, OpenCode plugin files, CLI/Keychain/config stores, browser cookies) and deduplicates them by stable account metadata 3. **Parallel Fetching**: Queries all provider APIs simultaneously using TaskGroup 4. **Smart Caching**: Falls back to cached data on network errors 5. **Graceful Degradation**: Shows available providers even if some fail @@ -377,6 +383,13 @@ The app searches for `auth.json` in these locations (in order): 2. `~/.local/share/opencode/auth.json` (default) 3. `~/Library/Application Support/opencode/auth.json` (macOS fallback) +For ChatGPT/Codex multi-account setups, the app also searches: +1. `~/.opencode/auth/openai.json` +2. `~/.opencode/openai-codex-accounts.json` +3. `~/.opencode/projects/*/openai-codex-accounts.json` + +If `oc-chatgpt-multi-auth` is installed and OpenCode sets `provider.openai.options.baseURL` to a localhost proxy, OpenCode Bar still queries the direct ChatGPT usage endpoint by default. Only the explicit `opencode-bar.codex.usageURL` override changes the usage endpoint. + ### GitHub Copilot not showing GitHub Copilot accounts are discovered from multiple sources (in priority order): 1. **OpenCode auth** — `copilot` entry in OpenCode `auth.json` diff --git a/default.profraw b/default.profraw deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/AI_USAGE_API_REFERENCE.md b/docs/AI_USAGE_API_REFERENCE.md index a802ed17..17668b21 100644 --- a/docs/AI_USAGE_API_REFERENCE.md +++ b/docs/AI_USAGE_API_REFERENCE.md @@ -7,7 +7,8 @@ | Provider | Token File | |----------|-----------| | Claude | `~/.config/opencode/opencode-anthropic-auth/accounts.json`, `~/.local/share/opencode/auth.json`, `~/.config/claude-code/auth.json`, macOS Keychain (`Claude Code-credentials`, `Claude Code`) | -| Codex, Copilot, Nano-GPT, MiniMax | `~/.local/share/opencode/auth.json` | +| Codex / ChatGPT | `~/.local/share/opencode/auth.json`, `~/.opencode/auth/openai.json`, `~/.opencode/openai-codex-accounts.json`, `~/.opencode/projects/*/openai-codex-accounts.json`, `~/.codex/auth.json`, `~/.codex-lb/` | +| Copilot, Nano-GPT, MiniMax | `~/.local/share/opencode/auth.json` | | Antigravity (Gemini) | `~/.config/opencode/antigravity-accounts.json` | | Antigravity (Local cache) | `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb` | @@ -69,15 +70,23 @@ The bundled [`scripts/query-claude.sh`](/Users/kargnas/projects/opencode-bar/scr **Endpoint:** `GET https://chatgpt.com/backend-api/wham/usage` +OpenCode Bar uses the direct ChatGPT usage endpoint by default. If `oc-chatgpt-multi-auth` sets OpenCode's `provider.openai.options.baseURL` to a localhost proxy, that proxy is ignored for usage requests unless `opencode-bar.codex.usageURL` is explicitly configured. + ```bash ACCESS=$(jq -r '.openai.access' ~/.local/share/opencode/auth.json) -ACCOUNT_ID=$(jq -r '.openai.accountId' ~/.local/share/opencode/auth.json) +ACCOUNT_ID=$(jq -r ' + .openai.accountIdOverride + // .openai.organizationIdOverride + // .openai.accountId +' ~/.local/share/opencode/auth.json) curl -s "https://chatgpt.com/backend-api/wham/usage" \ -H "Authorization: Bearer $ACCESS" \ -H "ChatGPT-Account-Id: $ACCOUNT_ID" ``` +For `oc-chatgpt-multi-auth` account files, prefer the canonical ChatGPT account ID from the access token claims when present. The plugin's `accountId` may be an organization ID (`org-*`) for the selected workspace, while the JWT claim `https://api.openai.com/auth.chatgpt_account_id` is the stable per-account identifier. + **Response:** ```json { @@ -398,7 +407,13 @@ Client Secret: Set GEMINI_CLIENT_SECRET environment variable "access": "eyJ...", "refresh": "rt_...", "expires": 1770563557150, - "accountId": "uuid" + "accountId": "uuid", + "idToken": "eyJ...", + "multiAccount": true, + "accountIdOverride": "org-selected-account", + "organizationIdOverride": "org-selected-account", + "accountIdSource": "org", + "accountLabel": "Personal [id:abc123]" }, "github-copilot": { "type": "oauth", @@ -413,6 +428,44 @@ Client Secret: Set GEMINI_CLIENT_SECRET environment variable } ``` +`oc-chatgpt-multi-auth` may leave `accountId` unset in `auth.json` and instead store the selected workspace in `accountIdOverride` / `organizationIdOverride`. OpenCode Bar derives the canonical ChatGPT account ID from the OpenAI JWT claims and keeps the override value as additional metadata when needed. + +### OpenCode ChatGPT Multi-Auth (`~/.opencode/projects/*/openai-codex-accounts.json`) + +```json +{ + "version": 3, + "accounts": [ + { + "accountId": "org-example-account", + "organizationId": "org-example-account", + "accountIdSource": "org", + "accountLabel": "Personal [id:abc123]", + "email": "user@example.com", + "refreshToken": "oaistb_rt_...", + "accessToken": "eyJ...", + "expiresAt": 1776088595278 + }, + { + "accountId": "058af373-bff1-4490-98b7-2a71290ae604", + "accountIdSource": "token", + "accountLabel": "Token account [id:0ae604]", + "email": "user@example.com", + "refreshToken": "oaistb_rt_...", + "accessToken": "eyJ...", + "expiresAt": 1776088595278 + } + ], + "activeIndex": 0, + "activeIndexByFamily": { + "gpt-5.4": 0, + "gpt-5.4-mini": 0 + } +} +``` + +OpenCode Bar reads every entry in these files, canonicalizes account IDs from the JWT claims, and merges duplicates with the OpenCode auth, Codex native auth, and `codex-lb` sources. + ### Antigravity Accounts (`~/.config/opencode/antigravity-accounts.json`) ```json