Skip to content
Merged
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
20 changes: 17 additions & 3 deletions Quotio/Services/QuotaFetchers/CodexUsageMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,20 @@ nonisolated enum CodexUsageMapper {
for snapshot: CodexUsageResponseV2.WindowSnapshot,
fallback: StandardWindowKind
) -> StandardWindowKind {
guard let seconds = snapshot.limitWindowSeconds, seconds > 0 else { return fallback }
if seconds >= 6 * 24 * 60 * 60 { return .weekly }
if seconds <= 24 * 60 * 60 { return .session }
let day = 24 * 60 * 60
if let seconds = snapshot.limitWindowSeconds, seconds > 0 {
if seconds >= 6 * day { return .weekly }
if seconds <= day { return .session }
return fallback
}
// Heuristic, used only when the authoritative `limit_window_seconds` is
// absent. `reset_after_seconds` is the time REMAINING in the window, not
// the window's length, so it is only ever a lower bound: a horizon of
// more than a day rules out the 5h session window, but it cannot tell how
// long the window actually is, and a weekly window that is less than a day
// from resetting is indistinguishable from a session one and falls through
// to the positional fallback below.
if let resetAfter = snapshot.resetAfterSeconds, resetAfter > day { return .weekly }
return fallback
}

Expand Down Expand Up @@ -261,18 +272,21 @@ nonisolated struct CodexUsageResponseV2: Decodable {
struct WindowSnapshot: Decodable {
var usedPercent: Int
var resetAt: Int?
var resetAfterSeconds: Int?
var limitWindowSeconds: Int?

enum CodingKeys: String, CodingKey {
case usedPercent = "used_percent"
case resetAt = "reset_at"
case resetAfterSeconds = "reset_after_seconds"
case limitWindowSeconds = "limit_window_seconds"
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
usedPercent = (try Self.flexibleInt(container, forKey: .usedPercent)).clamped(to: 0...100)
resetAt = try? Self.flexibleInt(container, forKey: .resetAt)
resetAfterSeconds = try? Self.flexibleInt(container, forKey: .resetAfterSeconds)
limitWindowSeconds = try? Self.flexibleInt(container, forKey: .limitWindowSeconds)
}

Expand Down
171 changes: 171 additions & 0 deletions QuotioTests/CodexUsageMapperTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import XCTest
@testable import Quotio

final class CodexUsageMapperTests: XCTestCase {
private func map(_ json: String) throws -> ProviderQuotaData {
try CodexUsageMapper.map(data: Data(json.utf8))
}

/// Exact payload from issue #356: a Codex free account exposing only a
/// weekly window in `primary_window` must produce a single Weekly bucket,
/// not a Session bucket, and no fabricated second bucket.
func testFreeAccountWeeklyOnlyPrimaryWindowMapsToSingleWeeklyBucket() throws {
let quota = try map("""
{
"plan_type": "free",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 85,
"limit_window_seconds": 604800,
"reset_after_seconds": 301573,
"reset_at": 1773507681
},
"secondary_window": null
}
}
""")

XCTAssertEqual(quota.models.map(\.name), ["codex-weekly"])
let weekly = try XCTUnwrap(quota.models.first)
XCTAssertEqual(weekly.usedPercentage, 85)
XCTAssertEqual(weekly.percentage, 15)
XCTAssertEqual(weekly.displayName, "Weekly")
XCTAssertFalse(quota.models.contains { $0.name == "codex-session" })
XCTAssertEqual(quota.planType, "free")
XCTAssertFalse(quota.isForbidden)
}

/// Paid accounts keep the existing labels: 5h primary window is Session,
/// 7-day secondary window is Weekly.
func testPaidAccountSessionPrimaryAndWeeklySecondaryKeepLabels() throws {
let quota = try map("""
{
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 40,
"limit_window_seconds": 18000,
"reset_after_seconds": 3600,
"reset_at": 1773507681
},
"secondary_window": {
"used_percent": 12,
"limit_window_seconds": 604800,
"reset_after_seconds": 301573,
"reset_at": 1773807681
}
}
}
""")

XCTAssertEqual(quota.models.map(\.name), ["codex-session", "codex-weekly"])
XCTAssertEqual(quota.models[0].usedPercentage, 40)
XCTAssertEqual(quota.models[1].usedPercentage, 12)
XCTAssertEqual(quota.models[0].displayName, "Session")
XCTAssertEqual(quota.models[1].displayName, "Weekly")
}

/// A null secondary window with a genuine 5h primary window yields only a
/// Session bucket.
func testSessionOnlyPrimaryWindowWithNullSecondaryYieldsSingleSessionBucket() throws {
let quota = try map("""
{
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 25,
"limit_window_seconds": 18000,
"reset_after_seconds": 3600,
"reset_at": 1773507681
},
"secondary_window": null
}
}
""")

XCTAssertEqual(quota.models.map(\.name), ["codex-session"])
XCTAssertEqual(quota.models[0].usedPercentage, 25)
}

/// Heuristic fallback: when `limit_window_seconds` is missing, a multi-day
/// `reset_after_seconds` rules out the 5h session window, which cannot reset
/// days in the future. This is a lower bound on the window, not its length.
func testMissingWindowSecondsClassifiesByResetHorizon() throws {
let quota = try map("""
{
"plan_type": "free",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 85,
"reset_after_seconds": 301573,
"reset_at": 1773507681
},
"secondary_window": null
}
}
""")

XCTAssertEqual(quota.models.map(\.name), ["codex-weekly"])
}

/// The heuristic's known blind spot: a window less than a day from resetting
/// carries no usable duration signal, so the mapper keeps the positional
/// fallback and existing paid-account responses are unaffected.
func testMissingDurationSignalsFallBackToPositionalLabels() throws {
let quota = try map("""
{
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 40,
"reset_after_seconds": 3600,
"reset_at": 1773507681
},
"secondary_window": {
"used_percent": 12,
"reset_at": 1773807681
}
}
}
""")

XCTAssertEqual(quota.models.map(\.name), ["codex-session", "codex-weekly"])
}

/// Two windows resolving to the same kind are deduplicated instead of
/// rendering a duplicate bucket.
func testDuplicateWindowKindsAreDeduplicated() throws {
let quota = try map("""
{
"plan_type": "free",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 85,
"limit_window_seconds": 604800,
"reset_at": 1773507681
},
"secondary_window": {
"used_percent": 20,
"limit_window_seconds": 604800,
"reset_at": 1773807681
}
}
}
""")

XCTAssertEqual(quota.models.map(\.name), ["codex-weekly"])
XCTAssertEqual(quota.models[0].usedPercentage, 85)
}
}