Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 152 additions & 32 deletions Sources/CodexBarCore/UsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,20 @@ private struct RPCCreditsSnapshot: Decodable, Encodable {
let balance: String?
}

private struct RPCRateLimitsErrorBody: Decodable {
let email: String?
let planType: String?
let rateLimit: CodexUsageResponse.RateLimitDetails?
let credits: CodexUsageResponse.CreditDetails?

enum CodingKeys: String, CodingKey {
case email
case planType = "plan_type"
case rateLimit = "rate_limit"
case credits
}
}

private enum RPCWireError: Error, LocalizedError {
case startFailed(String)
case requestFailed(String)
Expand Down Expand Up @@ -566,33 +580,40 @@ public struct UsageFetcher: Sendable {
}

private func loadRPCUsage() async throws -> UsageSnapshot {
let rpc = try CodexRPCClient(environment: self.environment)
defer { rpc.shutdown() }

try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4")
// The app-server answers on a single stdout stream, so keep requests
// serialized to avoid starving one reader when multiple awaiters race
// for the same pipe.
let limits = try await rpc.fetchRateLimits().rateLimits
let account = try? await rpc.fetchAccount()
do {
let rpc = try CodexRPCClient(environment: self.environment)
defer { rpc.shutdown() }

let identity = ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: account?.account.flatMap { details in
if case let .chatgpt(email, _) = details { email } else { nil }
},
accountOrganization: nil,
loginMethod: account?.account.flatMap { details in
if case let .chatgpt(_, plan) = details { plan } else { nil }
})
guard let state = CodexReconciledState.fromCLI(
primary: Self.makeWindow(from: limits.primary),
secondary: Self.makeWindow(from: limits.secondary),
identity: identity)
else {
throw UsageError.noRateLimitsFound
try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4")
// The app-server answers on a single stdout stream, so keep requests
// serialized to avoid starving one reader when multiple awaiters race
// for the same pipe.
let limits = try await rpc.fetchRateLimits().rateLimits
let account = try? await rpc.fetchAccount()

let identity = ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: account?.account.flatMap { details in
if case let .chatgpt(email, _) = details { email } else { nil }
},
accountOrganization: nil,
loginMethod: account?.account.flatMap { details in
if case let .chatgpt(_, plan) = details { plan } else { nil }
})
guard let state = CodexReconciledState.fromCLI(
primary: Self.makeWindow(from: limits.primary),
secondary: Self.makeWindow(from: limits.secondary),
identity: identity)
else {
throw UsageError.noRateLimitsFound
}
return state.toUsageSnapshot()
} catch {
if let snapshot = Self.recoverUsageFromRPCError(error) {
return snapshot
}
throw error
}
return state.toUsageSnapshot()
}

private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot {
Expand Down Expand Up @@ -625,13 +646,20 @@ public struct UsageFetcher: Sendable {
}

private func loadRPCCredits() async throws -> CreditsSnapshot {
let rpc = try CodexRPCClient(environment: self.environment)
defer { rpc.shutdown() }
try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4")
let limits = try await rpc.fetchRateLimits().rateLimits
guard let credits = limits.credits else { throw UsageError.noRateLimitsFound }
let remaining = Self.parseCredits(credits.balance)
return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date())
do {
let rpc = try CodexRPCClient(environment: self.environment)
defer { rpc.shutdown() }
try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4")
let limits = try await rpc.fetchRateLimits().rateLimits
guard let credits = limits.credits else { throw UsageError.noRateLimitsFound }
let remaining = Self.parseCredits(credits.balance)
return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date())
} catch {
if let credits = Self.recoverCreditsFromRPCError(error) {
return credits
}
throw error
}
}

private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot {
Expand Down Expand Up @@ -712,6 +740,16 @@ public struct UsageFetcher: Sendable {
resetDescription: resetDescription)
}

private static func makeWindow(from response: CodexUsageResponse.WindowSnapshot?) -> RateWindow? {
guard let response else { return nil }
let resetsAtDate = Date(timeIntervalSince1970: TimeInterval(response.resetAt))
return RateWindow(
usedPercent: Double(response.usedPercent),
windowMinutes: response.limitWindowSeconds / 60,
resetsAt: resetsAtDate,
resetDescription: UsageFormatter.resetDescription(from: resetsAtDate))
}

private static func makeTTYWindow(
percentLeft: Int?,
windowMinutes: Int,
Expand All @@ -731,6 +769,80 @@ public struct UsageFetcher: Sendable {
return val
}

private static func recoverUsageFromRPCError(_ error: Error) -> UsageSnapshot? {
guard let body = self.decodeRateLimitsErrorBody(from: error) else { return nil }
let identity = ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: self.normalizedCodexAccountField(body.email),
accountOrganization: nil,
loginMethod: self.normalizedCodexAccountField(body.planType))
guard let state = CodexReconciledState.fromCLI(
primary: self.makeWindow(from: body.rateLimit?.primaryWindow),
secondary: self.makeWindow(from: body.rateLimit?.secondaryWindow),
identity: identity)
else {
return nil
}
return state.toUsageSnapshot()
}

private static func recoverCreditsFromRPCError(_ error: Error) -> CreditsSnapshot? {
guard let credits = self.decodeRateLimitsErrorBody(from: error)?.credits else { return nil }
guard let remaining = credits.balance else { return nil }
return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date())
}

private static func decodeRateLimitsErrorBody(from error: Error) -> RPCRateLimitsErrorBody? {
guard case let RPCWireError.requestFailed(message) = error else { return nil }
guard let json = self.extractJSONObject(after: "body=", in: message) else { return nil }
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(RPCRateLimitsErrorBody.self, from: data)
}

private static func extractJSONObject(after marker: String, in text: String) -> String? {
guard let markerRange = text.range(of: marker) else { return nil }
let suffix = text[markerRange.upperBound...]
guard let start = suffix.firstIndex(of: "{") else { return nil }

var depth = 0
var inString = false
var isEscaped = false
var endIndex: String.Index?

for index in suffix[start...].indices {
let character = suffix[index]

if inString {
if isEscaped {
isEscaped = false
} else if character == "\\" {
isEscaped = true
} else if character == "\"" {
inString = false
}
continue
}

switch character {
case "\"":
inString = true
case "{":
depth += 1
case "}":
depth -= 1
if depth == 0 {
endIndex = index
break
Comment on lines +833 to +835
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Break out once JSON body closes

In extractJSONObject, the break inside the case "}" branch only exits the switch, not the surrounding for loop, so parsing continues after the first balanced JSON object. If the RPC error message includes additional diagnostic text with braces after body={...} (for example headers={...}), endIndex is overwritten and the returned slice is no longer valid JSON, causing usage/credits recovery to silently fail for that message format.

Useful? React with 👍 / 👎.

}
Comment on lines +826 to +836
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractJSONObject sets endIndex when depth == 0, but the break here only exits the switch, not the surrounding for loop. That means parsing continues past the end of the JSON object, which can lead to incorrect depth tracking (potentially negative) and unnecessary scanning if there are additional braces later in the message. Use a labeled break/early return once the matching closing brace is found.

Copilot uses AI. Check for mistakes.
default:
break
}
}

guard let endIndex else { return nil }
return String(suffix[start...endIndex])
}

private static func normalizedCodexAccountField(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
Expand Down Expand Up @@ -790,6 +902,14 @@ extension UsageFetcher {
return state.toUsageSnapshot()
}

public static func _recoverCodexRPCUsageFromErrorForTesting(_ message: String) -> UsageSnapshot? {
self.recoverUsageFromRPCError(RPCWireError.requestFailed(message))
}

public static func _recoverCodexRPCCreditsFromErrorForTesting(_ message: String) -> CreditsSnapshot? {
self.recoverCreditsFromRPCError(RPCWireError.requestFailed(message))
}

private static func makeTestingWindow(
_ value: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?))
-> RateWindow
Expand Down
53 changes: 53 additions & 0 deletions Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ import Foundation
import Testing

struct CodexUsageFetcherFallbackTests {
@Test
func `CLI usage recovers from RPC decode mismatch body payload`() {
let snapshot = UsageFetcher._recoverCodexRPCUsageFromErrorForTesting(
Self.decodeMismatchBodyMessage)

#expect(snapshot?.primary?.usedPercent == 4)
#expect(snapshot?.primary?.windowMinutes == 300)
#expect(snapshot?.secondary?.usedPercent == 19)
#expect(snapshot?.secondary?.windowMinutes == 10080)
#expect(snapshot?.accountEmail(for: UsageProvider.codex) == "[email protected]")
#expect(snapshot?.loginMethod(for: UsageProvider.codex) == "prolite")
}

@Test
func `CLI credits recover from RPC decode mismatch body payload`() {
let credits = UsageFetcher._recoverCodexRPCCreditsFromErrorForTesting(Self.decodeMismatchBodyMessage)

#expect(credits?.remaining == 0)
}

@Test
func `CLI usage falls back from RPC decode mismatch to TTY status`() async throws {
let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI()
Expand All @@ -28,6 +48,39 @@ struct CodexUsageFetcherFallbackTests {
#expect(credits.remaining == 42)
}

private static let decodeMismatchBodyMessage = """
failed to fetch codex rate limits: Decode error for https://chatgpt.com/backend-api/wham/usage:
unknown variant `prolite`, expected one of `guest`, `free`, `go`, `plus`, `pro`;
content-type=application/json; body={
"user_id": "user-HjRmNJhdtyqaGzIB98OrOdJw",
"account_id": "user-HjRmNJhdtyqaGzIB98OrOdJw",
"email": "[email protected]",
"plan_type": "prolite",
Comment on lines +55 to +58
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test payload includes real-looking personal data (email and user_id/account_id). Even in tests, embedding potentially real PII is risky (logs, crash reports, public repo visibility). Replace these with clearly fake placeholder values (e.g., user-TEST, [email protected]) while keeping the shape of the payload the same.

Copilot uses AI. Check for mistakes.
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 4,
"limit_window_seconds": 18000,
"reset_after_seconds": 8657,
"reset_at": 1776216359
},
"secondary_window": {
"used_percent": 19,
"limit_window_seconds": 604800,
"reset_after_seconds": 187681,
"reset_at": 1776395384
}
},
"credits": {
"has_credits": false,
"unlimited": false,
"overage_limit_reached": false,
"balance": "0E-10"
}
}
"""

private func makeDecodeMismatchStubCodexCLI() throws -> String {
let script = """
#!/usr/bin/python3
Expand Down
Loading