relay-codex 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,10 @@ at Mac login. `relay menubar --no-startup --configure-only` disables login start
68
68
  Use a session's menu to switch accounts after its work finishes. Account menus can
69
69
  enable or disable future routing without stopping current tasks. Actions show
70
70
  their result, and stopped workers offer a restart.
71
+ Account menus also show earned usage resets when available. Choose **Use available
72
+ reset…** and confirm to spend one; Relay refreshes allowance afterward. The button
73
+ is enabled only with weekly allowance at 0% and its reset more than 48 hours away. Recovery
74
+ never spends these resets automatically.
71
75
 
72
76
  For tab completion in Zsh, run `source <(relay completion zsh)` after `compinit`.
73
77
  Bash users can run `source <(relay completion bash)`. Add the same line to your
package/docs/commands.md CHANGED
@@ -40,6 +40,7 @@ relay account priority personal 10
40
40
  relay account cap personal 2
41
41
  relay account models personal YOUR_MODEL
42
42
  relay account reauth personal
43
+ relay account reset personal --yes
43
44
  relay account remove personal
44
45
  ```
45
46
 
@@ -49,6 +50,28 @@ label, unique email, or chosen display name. Renaming keeps the login usable.
49
50
  A session cap of zero means unlimited. Removing an account retains its login
50
51
  unless you request `--revoke`.
51
52
 
53
+ The account menu shows **Usage resets available** and **Use available reset…**
54
+ when Codex reports an earned reset. It is enabled only when weekly allowance is
55
+ fully exhausted (0%) and the weekly reset is **more than 48 hours away**. Exactly
56
+ 48 hours, any weekly allowance left, or unknown weekly data keeps it disabled.
57
+ The reason appears below the button. Choose it and confirm to use one. If the count
58
+ is unknown, choose **Check allowance now** first. Reset availability is separate
59
+ from purchased workspace credits. Resets are never spent by automatic recovery.
60
+
61
+ The equivalent terminal command is `relay account reset ACCOUNT --yes`. If a
62
+ response is lost, use the same action again: Relay saves and reuses the pending
63
+ request rather than submitting a new redemption. Pending retries must satisfy
64
+ the same weekly rule; their saved request is retained when locked. The menu calls this **Check
65
+ previous reset…**. An optional `--request-id UUID` also identifies a specific
66
+ logical attempt. Codex chooses the available reset; Relay checks the returned
67
+ result and reads allowance again afterward.
68
+
69
+ A reset only releases quota blocks when refreshed allowance permits it. Paused
70
+ tasks, required reviews, and unrelated retry delays are retained. If the reset
71
+ was used but allowance refresh failed, check allowance again; do not spend
72
+ another reset to refresh the display.
73
+ This uses the [documented Codex reset interface](https://learn.chatgpt.com/docs/app-server#8-earned-rate-limit-resets-chatgpt).
74
+
52
75
  ## Launch and recovery
53
76
 
54
77
  ```sh
@@ -8,8 +8,8 @@ Friends can run `npx relay-codex` or install it with `npm install -g relay-codex
8
8
  The GitHub repository is enough for friends to install or try Relay:
9
9
 
10
10
  ```sh
11
- npm install -g github:blackspider-ops/relay#v0.6.1
12
- npx --yes github:blackspider-ops/relay#v0.6.1 --version
11
+ npm install -g github:blackspider-ops/relay#v0.7.1
12
+ npx --yes github:blackspider-ops/relay#v0.7.1 --version
13
13
  ```
14
14
 
15
15
  `npx` runs packages; it is not a separate publishing service. Publishing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-codex",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "Keep your Codex sessions moving between your own accounts.",
5
5
  "license": "MIT",
6
6
  "author": "blackspider-ops",
package/python/Menu.swift CHANGED
@@ -12,6 +12,8 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
12
12
  var reconnect: DispatchWorkItem?
13
13
  var stopping = false
14
14
  var snapshotInFlight = false
15
+ var resetExpiry: DispatchWorkItem?
16
+ var menuOpen = false
15
17
  let command = Bundle.main.object(forInfoDictionaryKey: "RelayCommand") as? String ?? "/usr/bin/false"
16
18
  let state = Bundle.main.object(forInfoDictionaryKey: "RelayState") as? String ?? ""
17
19
 
@@ -140,6 +142,15 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
140
142
  guard let seconds = value as? Double, seconds.isFinite, seconds > 0 else { return nil }
141
143
  return seconds
142
144
  }
145
+ static func resetBlockReason(_ account: [String: Any], now: Double = Date().timeIntervalSince1970) -> String? {
146
+ guard let weekly = account["secondary"] as? [String: Any], let used = (weekly["usedPercent"] as? NSNumber)?.doubleValue, used.isFinite else {
147
+ return "Reset locked: check allowance to confirm the weekly limit and reset time."
148
+ }
149
+ guard used >= 100 else { return "Reset locked: weekly allowance must be fully exhausted (0%)." }
150
+ guard let at = timestamp(weekly["resetsAt"]) else { return "Reset locked: check allowance to confirm the weekly limit and reset time." }
151
+ guard at - now > 48 * 60 * 60 else { return "Reset locked: the weekly reset must be more than 48 hours away." }
152
+ return nil
153
+ }
143
154
  static func accountTimingRows(_ account: [String: Any]) -> [TimingRow] {
144
155
  var rows: [TimingRow] = []
145
156
  if let primary = account["primary"] as? [String: Any], let at = timestamp(primary["resetsAt"]) {
@@ -182,6 +193,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
182
193
  updateTimes(menu)
183
194
  if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
184
195
  else { item.menu = menu }
196
+ if menuOpen { scheduleResetExpiry() }
185
197
  }
186
198
  func makeMenu() -> NSMenu {
187
199
  let sessions = current["sessions"] as? [[String: Any]] ?? []
@@ -227,6 +239,17 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
227
239
  let lastChecked = MenuController.timestamp(account["usage_checked_at"]).map { date($0) } ?? "Not checked yet"
228
240
  text("Last checked: \(lastChecked)", into: submenu)
229
241
  if let id = account["id"] as? String {
242
+ submenu.addItem(.separator())
243
+ let pendingReset = account["reset_pending"] as? Bool ?? false
244
+ let resetCount = (account["reset_credits"] as? NSNumber)?.intValue
245
+ if let count = resetCount { text("Usage resets available: \(count)", into: submenu) }
246
+ else { text("Usage resets: check allowance to see availability.", into: submenu) }
247
+ if pendingReset || (resetCount ?? 0) > 0 {
248
+ let blocked = MenuController.resetBlockReason(account)
249
+ let reset = action(pendingReset ? "Check previous reset…" : "Use available reset…", args: ["confirm-reset", id, name, pendingReset ? "pending" : "new"], into: submenu)
250
+ reset.isEnabled = !actionRunning && !(account["maintenance"] as? Bool ?? false) && blocked == nil
251
+ if let blocked = blocked { text(blocked, into: submenu) }
252
+ }
230
253
  submenu.addItem(.separator())
231
254
  let enabled = account["enabled"] as? Bool ?? true
232
255
  action(enabled ? "Disable for future routing" : "Enable for routing", args: ["account", enabled ? "disable" : "enable", id], into: submenu)
@@ -289,12 +312,37 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
289
312
  return menu
290
313
  }
291
314
  func menuWillOpen(_ menu: NSMenu) {
315
+ menuOpen = true
316
+ render()
292
317
  refreshSnapshot()
293
318
  updateTimes(menu)
294
319
  for entry in menu.items where entry.title == "Open at login" {
295
320
  entry.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
296
321
  }
297
322
  }
323
+ func scheduleResetExpiry() {
324
+ resetExpiry?.cancel()
325
+ let now = Date().timeIntervalSince1970
326
+ let accounts = current["accounts"] as? [[String: Any]] ?? []
327
+ let boundaries = accounts.compactMap { account -> Double? in
328
+ guard MenuController.resetBlockReason(account, now: now) == nil, let weekly = account["secondary"] as? [String: Any], let at = MenuController.timestamp(weekly["resetsAt"]) else { return nil }
329
+ return at - 48 * 60 * 60
330
+ }
331
+ if let boundary = boundaries.min() {
332
+ let expiry = DispatchWorkItem { [weak self] in self?.render() }
333
+ resetExpiry = expiry
334
+ DispatchQueue.main.asyncAfter(deadline: .now() + max(0, boundary - now), execute: expiry)
335
+ }
336
+ }
337
+ func menuDidClose(_ menu: NSMenu) { menuOpen = false; resetExpiry?.cancel(); resetExpiry = nil }
338
+ func resetAllowed(_ id: String) -> Bool {
339
+ let accounts = current["accounts"] as? [[String: Any]] ?? []
340
+ guard let account = accounts.first(where: { $0["id"] as? String == id }) else {
341
+ actionNotice = "Reset locked: account details are unavailable."; actionFailed = true; render(); return false
342
+ }
343
+ if let reason = MenuController.resetBlockReason(account) { actionNotice = reason; actionFailed = true; render(); return false }
344
+ return true
345
+ }
298
346
  @objc func runAction(_ sender: NSMenuItem) {
299
347
  guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
300
348
  if args[0] == "copy" { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args[1], forType: .string); return }
@@ -307,16 +355,31 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
307
355
  guard alert.runModal() == .alertFirstButtonReturn else { return }
308
356
  args = ["retry", args[1], "--wait"]
309
357
  }
358
+ if args[0] == "confirm-reset" {
359
+ guard resetAllowed(args[1]) else { return }
360
+ let pending = args[3] == "pending"
361
+ let alert = NSAlert(); alert.messageText = pending ? "Check the previous reset?" : "Use one usage reset?"
362
+ alert.informativeText = args[2] + "\n\n" + (pending ? "Relay will check the same pending request. If it already used a reset, another one will not be spent." : "This spends one earned reset on eligible usage limits for this account. Allowance will be checked afterward. Paused tasks and review holds stay paused.")
363
+ alert.addButton(withTitle: pending ? "Check reset" : "Use reset"); alert.addButton(withTitle: "Cancel")
364
+ NSApp.activate(ignoringOtherApps: true)
365
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
366
+ guard resetAllowed(args[1]) else { return }
367
+ args = ["account", "reset", args[1], "--yes", "--request-id", UUID().uuidString]
368
+ }
310
369
  actionRunning = true; actionFailed = false; actionNotice = "Applying change…"; render()
311
- let proc = process(args); let pipe = Pipe(); proc.standardOutput = FileHandle.nullDevice; proc.standardError = pipe
370
+ let isReset = Array(args.prefix(2)) == ["account", "reset"]
371
+ let proc = process(args); let pipe = Pipe()
372
+ if isReset { proc.standardOutput = pipe } else { proc.standardOutput = FileHandle.nullDevice }
373
+ proc.standardError = pipe
312
374
  DispatchQueue.global(qos: .utility).async { [weak self] in
313
375
  do {
314
376
  try proc.run(); let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit()
315
377
  let failed = proc.terminationStatus != 0
316
378
  let detail = String(data: bytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
379
+ let resetResult = detail.split(separator: "\n").last.map(String.init) ?? "Reset check finished. Check the account's allowance."
317
380
  DispatchQueue.main.async {
318
381
  self?.actionRunning = false; self?.actionFailed = failed
319
- self?.actionNotice = failed ? (detail.isEmpty ? "Action failed. Check relay events." : detail) : MenuController.successMessage(args)
382
+ self?.actionNotice = failed ? (detail.isEmpty ? "Action failed. Check relay events." : detail) : isReset ? resetResult : MenuController.successMessage(args)
320
383
  self?.render(); self?.refreshSnapshot()
321
384
  }
322
385
  } catch { DispatchQueue.main.async { self?.actionRunning = false; self?.actionFailed = true; self?.actionNotice = "Could not run that action."; self?.render() } }
@@ -390,6 +453,20 @@ if CommandLine.arguments.contains("--self-check") {
390
453
  let empty = controller.makeMenu()
391
454
  let correctEmpty = empty.items.contains { $0.title.contains("No Relay sessions running") } && empty.items.first { $0.identifier?.rawValue == "account:one" }?.state == .off
392
455
  controller.current["sessions"] = originalSessions
456
+ let originalAccounts = controller.current["accounts"]
457
+ controller.current["accounts"] = [["id": "one", "name": "One", "reset_credits": 2, "secondary": ["usedPercent": 100, "resetsAt": Date().timeIntervalSince1970 + 72 * 3600]], ["id": "two", "name": "Two", "reset_credits": 0], ["id": "unknown", "name": "Unknown"], ["id": "pending", "name": "Pending", "reset_pending": true]]
458
+ let resets = controller.makeMenu()
459
+ let correctResets = findAction(["confirm-reset", "one", "One", "new"], in: resets)?.isEnabled == true && findAction(["confirm-reset", "two", "Two", "new"], in: resets) == nil && findAction(["confirm-reset", "unknown", "Unknown", "new"], in: resets) == nil && findAction(["confirm-reset", "pending", "Pending", "pending"], in: resets) != nil
460
+ controller.actionRunning = true
461
+ let resetLocked = findAction(["confirm-reset", "one", "One", "new"], in: controller.makeMenu())?.isEnabled == false
462
+ let policyNow = 2000000000.0
463
+ let far: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172801]]
464
+ let exact: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172800]]
465
+ let near: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172799]]
466
+ let left: [String: Any] = ["secondary": ["usedPercent": 99.99, "resetsAt": policyNow + 259200]]
467
+ let correctPolicy = MenuController.resetBlockReason(far, now: policyNow) == nil && MenuController.resetBlockReason(exact, now: policyNow) != nil && MenuController.resetBlockReason(near, now: policyNow) != nil && MenuController.resetBlockReason(left, now: policyNow) != nil && MenuController.resetBlockReason([:], now: policyNow) != nil
468
+ let pendingLocked = findAction(["confirm-reset", "pending", "Pending", "pending"], in: resets)?.isEnabled == false
469
+ controller.current["accounts"] = originalAccounts
393
470
  controller.actionRunning = true; controller.actionNotice = "Applying change…"
394
471
  let pending = controller.makeMenu()
395
472
  let correctPending = findAction(switchArgs, in: pending)?.isEnabled == false && findAction(["refresh"], in: pending)?.isEnabled == false && findAction(["copy", "relay attach live"], in: pending)?.isEnabled == true
@@ -399,7 +476,9 @@ if CommandLine.arguments.contains("--self-check") {
399
476
  print("Account choices and stopped controls correct: \(correctChoices && stoppedControls)")
400
477
  print("Pending action protected and result retained: \(correctPending && retainedResult)")
401
478
  print("In-use accounts and current choice marked: \(correctMarker && correctMultiple && correctEmpty)")
402
- exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty ? 0 : 1)
479
+ print("Available and pending reset actions protected: \(correctResets && resetLocked)")
480
+ print("Weekly zero and more-than-48-hour guard enforced: \(correctPolicy && pendingLocked)")
481
+ exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty && correctResets && resetLocked && correctPolicy && pendingLocked ? 0 : 1)
403
482
  } else {
404
483
  let app = NSApplication.shared
405
484
  let delegate = MenuController(); app.delegate = delegate; app.run()
@@ -77,6 +77,7 @@ def remaining_percent(window):
77
77
 
78
78
  def snapshot(state, include_inactive=False):
79
79
  from router import labels, VERSION
80
+ import resets
80
81
 
81
82
  pool = Pool(state)
82
83
  try:
@@ -111,6 +112,9 @@ def snapshot(state, include_inactive=False):
111
112
  "weekly_remaining": remaining_percent(windows.get("secondary")),
112
113
  "usage_known": usage is not None,
113
114
  "usage_checked_at": pool.checked_at(label),
115
+ "reset_credits": resets.available(usage),
116
+ "reset_pending": resets.pending(state / "accounts" / label),
117
+ "reset_lock_reason": resets.weekly_block_reason(usage),
114
118
  "maintenance": options.get("maintenance", False),
115
119
  "next_check": min(
116
120
  (b["next_check"] for b in blocks if b["account"] == label),
@@ -313,6 +317,16 @@ async def _manage(args, state, binary):
313
317
  pool = Pool(state)
314
318
  try:
315
319
  label = resolve_label(state, args.label) if args.action != "add" else args.label
320
+ if args.action == "reset":
321
+ import resets
322
+
323
+ if not args.yes:
324
+ raise RouterError(
325
+ "This uses one earned usage reset for this account. Add --yes to confirm, or use the menu's Use available reset action."
326
+ )
327
+ result = await resets.consume(state, label, binary, args.request_id)
328
+ print(resets.message(result))
329
+ return 0
316
330
  if args.action in {"enable", "disable", "priority", "cap", "rename", "models"}:
317
331
  if args.action in {"enable", "disable"}:
318
332
  pool.set_options(label, enabled=args.action == "enable")
@@ -648,6 +662,7 @@ def add_parsers(sub):
648
662
  "priority",
649
663
  "cap",
650
664
  "models",
665
+ "reset",
651
666
  ]:
652
667
  p = child.add_parser(action)
653
668
  p.add_argument("label")
@@ -661,6 +676,16 @@ def add_parsers(sub):
661
676
  p.add_argument("value", type=int)
662
677
  if action == "models":
663
678
  p.add_argument("values", nargs="*")
679
+ if action == "reset":
680
+ p.add_argument(
681
+ "--yes",
682
+ action="store_true",
683
+ help="Confirm using one earned usage reset",
684
+ )
685
+ p.add_argument(
686
+ "--request-id",
687
+ help="Optional UUID; reuse it to check the same redemption attempt",
688
+ )
664
689
  config = sub.add_parser("config", help="Set global or named recovery profiles")
665
690
  config.add_argument("key", nargs="?")
666
691
  config.add_argument("value", nargs="?")
@@ -0,0 +1,228 @@
1
+ """Explicit earned-reset redemption. Never called by automatic recovery."""
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import time
7
+ import uuid
8
+
9
+ from pool import Pool, blocked, reset_time
10
+ from settings import atomic_json
11
+
12
+ OUTCOMES = {"reset", "alreadyRedeemed", "nothingToReset", "noCredit"}
13
+ WEEKLY_RESET_BUFFER = 48 * 60 * 60
14
+
15
+
16
+ def weekly_block_reason(snapshot, now=None):
17
+ window = ((snapshot or {}).get("rateLimits") or {}).get("secondary")
18
+ if not isinstance(window, dict):
19
+ return (
20
+ "Reset locked: check allowance to confirm the weekly limit and reset time."
21
+ )
22
+ used, at = window.get("usedPercent"), window.get("resetsAt")
23
+ if type(used) not in (int, float) or not math.isfinite(used):
24
+ return (
25
+ "Reset locked: check allowance to confirm the weekly limit and reset time."
26
+ )
27
+ if used < 100:
28
+ return "Reset locked: weekly allowance must be fully exhausted (0%)."
29
+ if type(at) not in (int, float) or not math.isfinite(at):
30
+ return (
31
+ "Reset locked: check allowance to confirm the weekly limit and reset time."
32
+ )
33
+ if at - (time.time() if now is None else now) <= WEEKLY_RESET_BUFFER:
34
+ return "Reset locked: the weekly reset must be more than 48 hours away."
35
+ return None
36
+
37
+
38
+ def available(snapshot):
39
+ summary = (snapshot or {}).get("rateLimitResetCredits")
40
+ count = summary.get("availableCount") if isinstance(summary, dict) else None
41
+ return count if type(count) is int and count >= 0 else None
42
+
43
+
44
+ def identity(home):
45
+ marker = (home / "enrolled").read_bytes()
46
+ return json.loads(marker).get("identity") or hashlib.sha256(marker).hexdigest()
47
+
48
+
49
+ def receipt(home):
50
+ path = home / "reset-attempt.json"
51
+ if not path.exists():
52
+ return {}
53
+ value = json.loads(path.read_text())
54
+ if not isinstance(value, dict):
55
+ raise ValueError(
56
+ "Unreadable reset receipt. Check this account before trying again."
57
+ )
58
+ return value
59
+
60
+
61
+ def pending(home):
62
+ try:
63
+ value = receipt(home)
64
+ return value.get("state") == "pending" and value.get("identity") == identity(
65
+ home
66
+ )
67
+ except (OSError, ValueError):
68
+ return False
69
+
70
+
71
+ def update_allowance(pool, label, snapshot):
72
+ """Only verified allowance can lift a quota block. Keep other retry reasons."""
73
+ if not isinstance(snapshot, dict) or not (
74
+ isinstance(snapshot.get("rateLimits"), dict)
75
+ or isinstance(snapshot.get("rateLimitsByLimitId"), dict)
76
+ or type(snapshot.get("ordinaryUsageAllowed")) is bool
77
+ ):
78
+ raise ValueError(
79
+ "Codex returned unrecognized allowance data; quota blocks were retained."
80
+ )
81
+ pool.save(label, snapshot)
82
+ with pool.db() as db:
83
+ scopes = [
84
+ r[0]
85
+ for r in db.execute(
86
+ "SELECT scope FROM blocks WHERE label=? AND reason='quota'", (label,)
87
+ )
88
+ ]
89
+ for scope in scopes:
90
+ model = None if scope == "*" else scope
91
+ if blocked(snapshot, model):
92
+ until = reset_time(snapshot, model) or time.time() + 300
93
+ db.execute(
94
+ "UPDATE blocks SET until=? WHERE label=? AND scope=? AND reason='quota'",
95
+ (until, label, scope),
96
+ )
97
+ else:
98
+ db.execute(
99
+ "DELETE FROM blocks WHERE label=? AND scope=? AND reason='quota'",
100
+ (label, scope),
101
+ )
102
+ pool.signal()
103
+
104
+
105
+ async def consume(state, label, binary, request_id=None):
106
+ from router import Account, RouterError, RemoteError, account_home, labels
107
+
108
+ if label not in labels(state):
109
+ raise RouterError("Choose an enrolled account from relay list.")
110
+ if request_id is not None:
111
+ try:
112
+ request_id = str(uuid.UUID(request_id))
113
+ except (ValueError, AttributeError):
114
+ raise RouterError("Use a UUID for --request-id.") from None
115
+ home = account_home(state, label)
116
+ pool = Pool(state)
117
+ account = Account(label, home, binary, pool)
118
+ try:
119
+ # The same per-account credential lock is used by old and new brokers.
120
+ # A reset does not require replacing the coordinator or a task's login.
121
+ async with account.operation() as rpc:
122
+ signed_in = identity(home)
123
+ previous = receipt(home)
124
+ same_identity = previous.get("identity") == signed_in
125
+ retry = same_identity and previous.get("state") == "pending"
126
+ known = (
127
+ same_identity
128
+ and request_id is not None
129
+ and previous.get("request_id") == request_id
130
+ and previous.get("state") == "done"
131
+ )
132
+ key = (
133
+ previous.get("request_id") if retry else request_id or str(uuid.uuid4())
134
+ )
135
+ if retry:
136
+ try:
137
+ key = str(uuid.UUID(key))
138
+ except (ValueError, AttributeError, TypeError):
139
+ raise RouterError(
140
+ "The pending reset receipt is unreadable. Check account usage before trying again."
141
+ ) from None
142
+ outcome = previous.get("outcome") if known else None
143
+ if known and (not isinstance(outcome, str) or outcome not in OUTCOMES):
144
+ raise RouterError(
145
+ "The saved reset result is unreadable. Check account usage before trying again."
146
+ )
147
+ if known and outcome == "reset":
148
+ outcome = "alreadyRedeemed"
149
+ if not known:
150
+ current = await rpc.call("account/rateLimits/read")
151
+ update_allowance(pool, label, current)
152
+ reason = weekly_block_reason(current)
153
+ if reason:
154
+ raise RouterError(reason)
155
+ count = available(current)
156
+ if count == 0 and not retry:
157
+ return {"outcome": "noCredit", "refreshed": True}
158
+ if count is None and not retry:
159
+ raise RouterError(
160
+ "Codex did not report reset availability. Check allowance again before using a reset."
161
+ )
162
+ if not known:
163
+ attempt = {
164
+ "request_id": key,
165
+ "identity": signed_in,
166
+ "state": "pending",
167
+ "at": time.time(),
168
+ }
169
+ atomic_json(home / "reset-attempt.json", attempt)
170
+ pool.invalidate(label)
171
+ pool.signal()
172
+ try:
173
+ response = await rpc.call(
174
+ "account/rateLimitResetCredit/consume", {"idempotencyKey": key}
175
+ )
176
+ outcome = response.get("outcome")
177
+ except RemoteError as error:
178
+ if error.payload.get("code") in {-32601, -32602}:
179
+ atomic_json(
180
+ home / "reset-attempt.json",
181
+ {**attempt, "state": "rejected"},
182
+ )
183
+ raise RouterError(
184
+ "This Codex runtime cannot use resets through Relay. Update Codex and try again."
185
+ ) from None
186
+ raise RouterError(
187
+ "Reset result could not be confirmed. Try this action again; Relay will reuse the same pending request."
188
+ ) from None
189
+ except Exception:
190
+ raise RouterError(
191
+ "Reset result could not be confirmed. Try this action again; Relay will reuse the same pending request."
192
+ ) from None
193
+ if not isinstance(outcome, str) or outcome not in OUTCOMES:
194
+ raise RouterError(
195
+ "Reset result was not recognized. Try again to check the same pending request."
196
+ )
197
+ atomic_json(
198
+ home / "reset-attempt.json",
199
+ {**attempt, "state": "done", "outcome": outcome},
200
+ )
201
+ refreshed = False
202
+ try:
203
+ snapshot = await rpc.call("account/rateLimits/read")
204
+ update_allowance(pool, label, snapshot)
205
+ refreshed = True
206
+ except Exception:
207
+ pool.invalidate(label)
208
+ pool.signal()
209
+ pool.record(
210
+ "reset_checked", account=label, outcome=outcome, refreshed=refreshed
211
+ )
212
+ return {"outcome": outcome, "refreshed": refreshed}
213
+ finally:
214
+ pool.close()
215
+
216
+
217
+ def message(result):
218
+ text = {
219
+ "reset": "Used one usage reset.",
220
+ "alreadyRedeemed": "The previous reset was already used; no additional reset was spent.",
221
+ "nothingToReset": "No usage window is eligible for a reset; no reset was used.",
222
+ "noCredit": "No usage resets are available; no reset was used.",
223
+ }[result["outcome"]]
224
+ return text + (
225
+ " Allowance updated."
226
+ if result["refreshed"]
227
+ else " Allowance could not be refreshed; choose Check allowance now."
228
+ )
package/python/router.py CHANGED
@@ -24,7 +24,7 @@ from pool import Pool, blocked, reset_time, failure_kind, file_lock
24
24
  from launch import translate, RESUME_FIELDS
25
25
  import settings
26
26
 
27
- VERSION = "0.6.1"
27
+ VERSION = "0.7.1"
28
28
  _support = Path.home() / "Library/Application Support"
29
29
  _legacy = _support / "Codex Accounts"
30
30
  _default_state = _legacy if (_legacy / "accounts").exists() else _support / "Relay"
@@ -2274,7 +2274,7 @@ def selected_binary(args, state):
2274
2274
  return None
2275
2275
  if (
2276
2276
  args.command == "account"
2277
- and args.action not in {"add", "reauth"}
2277
+ and args.action not in {"add", "reauth", "reset"}
2278
2278
  and not getattr(args, "revoke", False)
2279
2279
  ):
2280
2280
  return None
@@ -126,12 +126,45 @@ for raw in sys.stdin:
126
126
  elif method == "account/login/start":
127
127
  result = {"type": params["type"]}
128
128
  elif method == "account/rateLimits/read":
129
+ reset_count = change(lambda d: d.get("reset_credit_count", 1))
130
+ weekly = change(
131
+ lambda d: (
132
+ {
133
+ "usedPercent": d["reset_weekly_used"],
134
+ "resetsAt": d["reset_weekly_at"],
135
+ }
136
+ if "reset_weekly_used" in d
137
+ else None
138
+ )
139
+ )
129
140
  result = {
130
141
  "ordinaryUsageAllowed": True,
131
142
  "rateLimits": {
132
- "primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600}
143
+ "primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600},
144
+ "secondary": weekly,
145
+ },
146
+ "rateLimitResetCredits": {
147
+ "availableCount": reset_count,
148
+ "credits": None,
133
149
  },
134
150
  }
151
+ elif method == "account/rateLimitResetCredit/consume":
152
+
153
+ def redeem(data):
154
+ keys = data.setdefault("reset_keys", [])
155
+ key = params["idempotencyKey"]
156
+ if key in keys:
157
+ return {"outcome": "alreadyRedeemed"}
158
+ count = data.get("reset_credit_count", 1)
159
+ if count <= 0:
160
+ return {"outcome": "noCredit"}
161
+ keys.append(key)
162
+ data["reset_credit_count"] = count - 1
163
+ if "reset_weekly_used" in data:
164
+ data["reset_weekly_used"] = 0
165
+ return {"outcome": "reset"}
166
+
167
+ result = change(redeem)
135
168
  elif method in ("thread/start", "thread/fork"):
136
169
  thread = str(uuid.uuid4())
137
170
  value = {
@@ -0,0 +1,289 @@
1
+ """Earned-reset tests use synthetic accounts and never redeem a real credit."""
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ import tempfile
7
+ import time
8
+ import unittest
9
+ import uuid
10
+ from unittest.mock import patch
11
+
12
+ import commands
13
+ from pool import Pool
14
+ from router import Account, RouterError, RemoteError, parse_arguments
15
+ from settings import atomic_json
16
+ import resets
17
+
18
+
19
+ class ResetService:
20
+ def __init__(self):
21
+ self.count = 2
22
+ self.blocked = True
23
+ self.calls = []
24
+ self.completed = set()
25
+ self.lose_reply = False
26
+ self.fail_refresh = False
27
+ self.residual_limit = False
28
+ self.outcome = None
29
+ self.reject = False
30
+ self.weekly_used = 100
31
+ self.weekly_at = time.time() + 72 * 3600
32
+
33
+ async def call(self, method, params=None):
34
+ await asyncio.sleep(0)
35
+ if method == "account/rateLimits/read":
36
+ if self.fail_refresh and self.completed:
37
+ raise asyncio.TimeoutError()
38
+ return {
39
+ "ordinaryUsageAllowed": not self.blocked,
40
+ "rateLimits": {
41
+ "primary": {
42
+ "usedPercent": 100 if self.blocked else 0,
43
+ "resetsAt": time.time() + 3600,
44
+ },
45
+ "secondary": {
46
+ "usedPercent": self.weekly_used,
47
+ "resetsAt": self.weekly_at,
48
+ },
49
+ },
50
+ "rateLimitResetCredits": {
51
+ "availableCount": self.count,
52
+ "credits": None,
53
+ },
54
+ }
55
+ if method != "account/rateLimitResetCredit/consume":
56
+ raise AssertionError(method)
57
+ if self.reject:
58
+ raise RemoteError({"code": -32601})
59
+ key = params["idempotencyKey"]
60
+ self.calls.append(key)
61
+ if key in self.completed:
62
+ return {"outcome": "alreadyRedeemed"}
63
+ if self.outcome in {"noCredit", "nothingToReset"}:
64
+ if self.outcome == "noCredit":
65
+ self.count = 0
66
+ return {"outcome": self.outcome}
67
+ self.completed.add(key)
68
+ self.count -= 1
69
+ self.blocked = self.residual_limit
70
+ if self.lose_reply:
71
+ self.lose_reply = False
72
+ raise asyncio.TimeoutError()
73
+ return {"outcome": self.outcome or "reset"}
74
+
75
+
76
+ class ResetTests(unittest.IsolatedAsyncioTestCase):
77
+ async def asyncSetUp(self):
78
+ self.tmp = tempfile.TemporaryDirectory(prefix="relay-reset-test-")
79
+ self.root = Path(self.tmp.name)
80
+ self.home = self.root / "accounts" / "one"
81
+ self.home.mkdir(parents=True)
82
+ atomic_json(self.home / "enrolled", {"identity": "fake-account-A"})
83
+ self.pool = Pool(self.root)
84
+ self.service = ResetService()
85
+ service = self.service
86
+
87
+ class TestAccount(Account):
88
+ async def start(self):
89
+ self.rpc = service
90
+
91
+ async def close(self):
92
+ self.rpc = None
93
+
94
+ self.patch = patch("router.Account", TestAccount)
95
+ self.patch.start()
96
+
97
+ async def asyncTearDown(self):
98
+ self.patch.stop()
99
+ self.pool.close()
100
+ self.tmp.cleanup()
101
+
102
+ async def test_success_refreshes_allowance_and_only_lifts_verified_quota_blocks(
103
+ self,
104
+ ):
105
+ self.pool.mark("one", reason="quota")
106
+ self.pool.mark("one", "other-model", reason="auth")
107
+ self.pool.intent("thread", {"review_required": True})
108
+ self.pool.touch(paused=True)
109
+ result = await resets.consume(self.root, "one", "fake")
110
+ self.assertEqual(result, {"outcome": "reset", "refreshed": True})
111
+ self.assertEqual(resets.available(self.pool.cached("one")), 1)
112
+ with self.pool.db() as db:
113
+ self.assertEqual(
114
+ db.execute("SELECT scope,reason FROM blocks").fetchall(),
115
+ [("other-model", "auth")],
116
+ )
117
+ details = json.loads(
118
+ db.execute(
119
+ "SELECT details FROM session_details WHERE id=?",
120
+ (self.pool.session,),
121
+ ).fetchone()[0]
122
+ )
123
+ self.assertTrue(details["paused"])
124
+ self.assertTrue(self.pool.intent("thread")["review_required"])
125
+ self.assertEqual(
126
+ (self.home / "reset-attempt.json").stat().st_mode & 0o777, 0o600
127
+ )
128
+
129
+ async def test_lost_reply_reuses_pending_key_even_if_next_click_has_a_new_key(self):
130
+ self.service.lose_reply = True
131
+ with self.assertRaisesRegex(RouterError, "same pending request"):
132
+ await resets.consume(self.root, "one", "fake", str(uuid.uuid4()))
133
+ self.assertTrue(resets.pending(self.home))
134
+ account = commands.snapshot(self.root)["accounts"][0]
135
+ self.assertTrue(account["reset_pending"])
136
+ self.assertIsNone(account["reset_credits"])
137
+ result = await resets.consume(self.root, "one", "fake", str(uuid.uuid4()))
138
+ self.assertEqual(result["outcome"], "alreadyRedeemed")
139
+ self.assertEqual(len(set(self.service.calls)), 1)
140
+ self.assertEqual(self.service.count, 1)
141
+ self.assertFalse(resets.pending(self.home))
142
+
143
+ async def test_concurrent_duplicate_request_is_redeemed_once(self):
144
+ key = str(uuid.uuid4())
145
+ results = await asyncio.gather(
146
+ *(resets.consume(self.root, "one", "fake", key) for _ in range(2))
147
+ )
148
+ self.assertEqual({r["outcome"] for r in results}, {"reset", "alreadyRedeemed"})
149
+ self.assertEqual(self.service.calls, [key])
150
+
151
+ async def test_success_with_failed_refresh_does_not_claim_updated_limits(self):
152
+ self.service.fail_refresh = True
153
+ key = str(uuid.uuid4())
154
+ self.pool.mark("one", reason="quota")
155
+ result = await resets.consume(self.root, "one", "fake", key)
156
+ self.assertFalse(result["refreshed"])
157
+ self.assertIn("could not be refreshed", resets.message(result))
158
+ self.assertIsNone(self.pool.cached("one"))
159
+ self.assertGreater(self.pool.deadline("one"), time.time())
160
+ retry = await resets.consume(self.root, "one", "fake", key)
161
+ self.assertEqual(retry["outcome"], "alreadyRedeemed")
162
+ self.assertEqual(len(self.service.calls), 1)
163
+
164
+ async def test_remaining_limit_is_retained_after_a_reset(self):
165
+ self.service.residual_limit = True
166
+ self.pool.mark("one", reason="quota")
167
+ await resets.consume(self.root, "one", "fake")
168
+ self.assertGreater(self.pool.deadline("one"), time.time())
169
+
170
+ async def test_no_credit_or_nothing_to_reset_are_reported_without_success_claim(
171
+ self,
172
+ ):
173
+ for outcome in ("noCredit", "nothingToReset"):
174
+ self.service.outcome = outcome
175
+ self.service.count = 2
176
+ result = await resets.consume(self.root, "one", "fake")
177
+ self.assertEqual(result["outcome"], outcome)
178
+ self.assertIn("no reset was used", resets.message(result))
179
+ self.service.count = 0
180
+ self.service.calls.clear()
181
+ result = await resets.consume(self.root, "one", "fake")
182
+ self.assertEqual(result["outcome"], "noCredit")
183
+ self.assertFalse(self.service.calls)
184
+
185
+ async def test_unknown_outcome_remains_pending_and_unsupported_method_is_actionable(
186
+ self,
187
+ ):
188
+ self.service.outcome = "newUnknownOutcome"
189
+ with self.assertRaisesRegex(RouterError, "not recognized"):
190
+ await resets.consume(self.root, "one", "fake")
191
+ self.assertTrue(resets.pending(self.home))
192
+ self.service.reject = True
193
+ with self.assertRaisesRegex(RouterError, "Update Codex"):
194
+ await resets.consume(self.root, "one", "fake")
195
+ self.assertFalse(resets.pending(self.home))
196
+
197
+ async def test_cli_requires_confirmation_before_starting_redemption(self):
198
+ args = parse_arguments(["account", "reset", "one"])
199
+ with patch("resets.consume", side_effect=AssertionError("Unconfirmed reset")):
200
+ with self.assertRaisesRegex(RouterError, "--yes"):
201
+ await commands.manage(args, self.root, "fake")
202
+
203
+ async def test_weekly_guard_blocks_all_mutation_until_zero_and_more_than_48_hours(
204
+ self,
205
+ ):
206
+ for used, hours in ((0, 72), (99.99, 72), (100, 48), (100, 47), (100, -1)):
207
+ with self.subTest(used=used, hours=hours):
208
+ self.service.weekly_used = used
209
+ self.service.weekly_at = time.time() + hours * 3600
210
+ with self.assertRaisesRegex(RouterError, "Reset locked"):
211
+ await resets.consume(self.root, "one", "fake")
212
+ self.assertFalse(self.service.calls)
213
+ self.assertFalse((self.home / "reset-attempt.json").exists())
214
+ self.service.weekly_used = 100
215
+ self.service.weekly_at = time.time() + 72 * 3600
216
+ result = await resets.consume(self.root, "one", "fake")
217
+ self.assertEqual(result["outcome"], "reset")
218
+
219
+ async def test_pending_retry_cannot_bypass_a_changed_weekly_guard(self):
220
+ self.service.lose_reply = True
221
+ with self.assertRaises(RouterError):
222
+ await resets.consume(self.root, "one", "fake")
223
+ self.service.weekly_used = 0
224
+ with self.assertRaisesRegex(RouterError, "fully exhausted"):
225
+ await resets.consume(self.root, "one", "fake")
226
+ self.assertEqual(len(self.service.calls), 1)
227
+ self.assertTrue(resets.pending(self.home))
228
+ self.assertEqual(self.service.count, 1)
229
+
230
+ def test_weekly_guard_exact_boundary_unknown_values_and_unrounded_allowance(self):
231
+ now = 2000000000
232
+
233
+ def usage(used, at):
234
+ return {"rateLimits": {"secondary": {"usedPercent": used, "resetsAt": at}}}
235
+
236
+ self.assertIsNone(resets.weekly_block_reason(usage(100, now + 172801), now))
237
+ for used, at in (
238
+ (100, now + 172800),
239
+ (100, now + 172799),
240
+ (99.999, now + 200000),
241
+ (100, None),
242
+ (None, now + 200000),
243
+ (float("nan"), now + 200000),
244
+ (100, float("inf")),
245
+ ):
246
+ self.assertIsNotNone(resets.weekly_block_reason(usage(used, at), now))
247
+ self.assertIsNotNone(resets.weekly_block_reason({}, now))
248
+
249
+ async def test_real_jsonl_helper_redeems_and_refreshes_synthetic_account(self):
250
+ from test_services import fake_executable
251
+
252
+ self.patch.stop()
253
+ binary = fake_executable(self.root)
254
+ atomic_json(
255
+ self.home / "fake-history.json",
256
+ {
257
+ "threads": {},
258
+ "calls": [],
259
+ "reset_credit_count": 1,
260
+ "reset_weekly_used": 100,
261
+ "reset_weekly_at": time.time() + 72 * 3600,
262
+ },
263
+ )
264
+ result = await resets.consume(self.root, "one", binary)
265
+ self.assertEqual(result, {"outcome": "reset", "refreshed": True})
266
+ history = json.loads((self.home / "fake-history.json").read_text())
267
+ self.assertEqual(len(history["reset_keys"]), 1)
268
+ self.assertEqual(history["reset_credit_count"], 0)
269
+ self.assertEqual(
270
+ sum(c["method"] == "account/rateLimits/read" for c in history["calls"]), 2
271
+ )
272
+
273
+ def test_available_count_is_authoritative_and_invalid_values_stay_unknown(self):
274
+ self.assertEqual(
275
+ resets.available(
276
+ {"rateLimitResetCredits": {"availableCount": 3, "credits": []}}
277
+ ),
278
+ 3,
279
+ )
280
+ for count in (-1, True, "2", 1.5, None):
281
+ self.assertIsNone(
282
+ resets.available({"rateLimitResetCredits": {"availableCount": count}})
283
+ )
284
+ with self.assertRaises(ValueError):
285
+ resets.update_allowance(self.pool, "one", {})
286
+
287
+
288
+ if __name__ == "__main__":
289
+ unittest.main()