From 0be9fe7d984fc41a61ed653e8e544e2f964f980e Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Wed, 12 Aug 2026 12:34:50 +0700 Subject: [PATCH 1/2] fix(codex): classify quota windows by duration instead of position --- .../Antigravity/AntigravityQuotaFetcher.swift | 1 + .../QuotaFetchers/CodexUsageMapper.swift | 18 +- QuotioTests/CodexUsageMapperTests.swift | 192 ++++++++++++++++++ 3 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 QuotioTests/CodexUsageMapperTests.swift diff --git a/Quotio/Services/Antigravity/AntigravityQuotaFetcher.swift b/Quotio/Services/Antigravity/AntigravityQuotaFetcher.swift index fc8fd94b5..9a5b4b935 100644 --- a/Quotio/Services/Antigravity/AntigravityQuotaFetcher.swift +++ b/Quotio/Services/Antigravity/AntigravityQuotaFetcher.swift @@ -235,6 +235,7 @@ nonisolated struct ModelQuota: Codable, Identifiable, Sendable { // Codex quota names case "codex-session": return "Session" case "codex-weekly": return "Weekly" + case "codex-monthly": return "Monthly" case "codex-spark": return "Codex Spark" case "codex-spark-weekly": return "Codex Spark Weekly" case let name where name.hasPrefix("codex-"): diff --git a/Quotio/Services/QuotaFetchers/CodexUsageMapper.swift b/Quotio/Services/QuotaFetchers/CodexUsageMapper.swift index 03068cfa3..e3d18476f 100644 --- a/Quotio/Services/QuotaFetchers/CodexUsageMapper.swift +++ b/Quotio/Services/QuotaFetchers/CodexUsageMapper.swift @@ -54,9 +54,16 @@ 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 <= day { return .session } + if seconds >= 20 * day { return .monthly } + if seconds >= 6 * day { return .weekly } + return fallback + } + // Window length missing: a session (5h) window can never reset more than + // a day out, so a multi-day reset horizon identifies a weekly window. + if let resetAfter = snapshot.resetAfterSeconds, resetAfter > day { return .weekly } return fallback } @@ -209,11 +216,13 @@ nonisolated enum CodexUsageMapper { private enum StandardWindowKind { case session case weekly + case monthly var id: String { switch self { case .session: "codex-session" case .weekly: "codex-weekly" + case .monthly: "codex-monthly" } } } @@ -261,11 +270,13 @@ 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" } @@ -273,6 +284,7 @@ nonisolated struct CodexUsageResponseV2: Decodable { 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) } diff --git a/QuotioTests/CodexUsageMapperTests.swift b/QuotioTests/CodexUsageMapperTests.swift new file mode 100644 index 000000000..ba2fd2056 --- /dev/null +++ b/QuotioTests/CodexUsageMapperTests.swift @@ -0,0 +1,192 @@ +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) + } + + /// When `limit_window_seconds` is missing, a multi-day `reset_after_seconds` + /// still identifies a weekly window: a 5h session window can never reset + /// days in the future. + 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"]) + } + + /// Without any duration signal the mapper keeps the positional fallback so + /// 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"]) + } + + /// A ~30-day window is labeled Monthly instead of being folded into Weekly. + func testMonthlyWindowIsClassifiedAsMonthly() throws { + let quota = try map(""" + { + "plan_type": "free", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 60, + "limit_window_seconds": 2592000, + "reset_at": 1773507681 + }, + "secondary_window": null + } + } + """) + + XCTAssertEqual(quota.models.map(\.name), ["codex-monthly"]) + XCTAssertEqual(quota.models[0].displayName, "Monthly") + } + + /// 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) + } +} From 4c1baf75b2851c76e39f8810dba68ab1a3b11a23 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Thu, 13 Aug 2026 09:28:03 +0700 Subject: [PATCH 2/2] fix(codex): narrow window classification to session and weekly Address review feedback on #468: - Remove the monthly window bucket and its `>= 20 days` threshold. Issue #356 establishes a 7-day weekly window; a separate monthly bucket is out of scope for this fix and belongs in its own change. This also drops the `codex-monthly` kind and its hardcoded "Monthly" display name, so no unlocalized user-facing string is introduced. - Document the `reset_after_seconds` fallback as a heuristic. It reports the time remaining in a window, not the window's length, so it is only a lower bound: it rules out the 5h session window but cannot determine the actual window length, and a weekly window less than a day from resetting falls through to the positional fallback. The issue #356 payload regression test is unchanged and still green. --- .../Antigravity/AntigravityQuotaFetcher.swift | 1 - .../QuotaFetchers/CodexUsageMapper.swift | 14 ++++---- QuotioTests/CodexUsageMapperTests.swift | 33 ++++--------------- 3 files changed, 14 insertions(+), 34 deletions(-) diff --git a/Quotio/Services/Antigravity/AntigravityQuotaFetcher.swift b/Quotio/Services/Antigravity/AntigravityQuotaFetcher.swift index 9a5b4b935..fc8fd94b5 100644 --- a/Quotio/Services/Antigravity/AntigravityQuotaFetcher.swift +++ b/Quotio/Services/Antigravity/AntigravityQuotaFetcher.swift @@ -235,7 +235,6 @@ nonisolated struct ModelQuota: Codable, Identifiable, Sendable { // Codex quota names case "codex-session": return "Session" case "codex-weekly": return "Weekly" - case "codex-monthly": return "Monthly" case "codex-spark": return "Codex Spark" case "codex-spark-weekly": return "Codex Spark Weekly" case let name where name.hasPrefix("codex-"): diff --git a/Quotio/Services/QuotaFetchers/CodexUsageMapper.swift b/Quotio/Services/QuotaFetchers/CodexUsageMapper.swift index e3d18476f..2d2f05fdc 100644 --- a/Quotio/Services/QuotaFetchers/CodexUsageMapper.swift +++ b/Quotio/Services/QuotaFetchers/CodexUsageMapper.swift @@ -56,13 +56,17 @@ nonisolated enum CodexUsageMapper { ) -> StandardWindowKind { let day = 24 * 60 * 60 if let seconds = snapshot.limitWindowSeconds, seconds > 0 { - if seconds <= day { return .session } - if seconds >= 20 * day { return .monthly } if seconds >= 6 * day { return .weekly } + if seconds <= day { return .session } return fallback } - // Window length missing: a session (5h) window can never reset more than - // a day out, so a multi-day reset horizon identifies a weekly window. + // 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 } @@ -216,13 +220,11 @@ nonisolated enum CodexUsageMapper { private enum StandardWindowKind { case session case weekly - case monthly var id: String { switch self { case .session: "codex-session" case .weekly: "codex-weekly" - case .monthly: "codex-monthly" } } } diff --git a/QuotioTests/CodexUsageMapperTests.swift b/QuotioTests/CodexUsageMapperTests.swift index ba2fd2056..b84d5e4cf 100644 --- a/QuotioTests/CodexUsageMapperTests.swift +++ b/QuotioTests/CodexUsageMapperTests.swift @@ -93,9 +93,9 @@ final class CodexUsageMapperTests: XCTestCase { XCTAssertEqual(quota.models[0].usedPercentage, 25) } - /// When `limit_window_seconds` is missing, a multi-day `reset_after_seconds` - /// still identifies a weekly window: a 5h session window can never reset - /// days in the future. + /// 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(""" { @@ -116,8 +116,9 @@ final class CodexUsageMapperTests: XCTestCase { XCTAssertEqual(quota.models.map(\.name), ["codex-weekly"]) } - /// Without any duration signal the mapper keeps the positional fallback so - /// existing paid-account responses are unaffected. + /// 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(""" { @@ -141,28 +142,6 @@ final class CodexUsageMapperTests: XCTestCase { XCTAssertEqual(quota.models.map(\.name), ["codex-session", "codex-weekly"]) } - /// A ~30-day window is labeled Monthly instead of being folded into Weekly. - func testMonthlyWindowIsClassifiedAsMonthly() throws { - let quota = try map(""" - { - "plan_type": "free", - "rate_limit": { - "allowed": true, - "limit_reached": false, - "primary_window": { - "used_percent": 60, - "limit_window_seconds": 2592000, - "reset_at": 1773507681 - }, - "secondary_window": null - } - } - """) - - XCTAssertEqual(quota.models.map(\.name), ["codex-monthly"]) - XCTAssertEqual(quota.models[0].displayName, "Monthly") - } - /// Two windows resolving to the same kind are deduplicated instead of /// rendering a duplicate bucket. func testDuplicateWindowKindsAreDeduplicated() throws {