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