relay-codex 0.6.0 → 0.7.0

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,9 @@ 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. Recovery
73
+ never spends these resets automatically.
71
74
 
72
75
  For tab completion in Zsh, run `source <(relay completion zsh)` after `compinit`.
73
76
  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,24 @@ 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. Choose it and confirm to use one. If the count
55
+ is unknown, choose **Check allowance now** first. Reset availability is separate
56
+ from purchased workspace credits. Resets are never spent by automatic recovery.
57
+
58
+ The equivalent terminal command is `relay account reset ACCOUNT --yes`. If a
59
+ response is lost, use the same action again: Relay saves and reuses the pending
60
+ request rather than submitting a new redemption. The menu calls this **Check
61
+ previous reset…**. An optional `--request-id UUID` also identifies a specific
62
+ logical attempt. Codex chooses the available reset; Relay checks the returned
63
+ result and reads allowance again afterward.
64
+
65
+ A reset only releases quota blocks when refreshed allowance permits it. Paused
66
+ tasks, required reviews, and unrelated retry delays are retained. If the reset
67
+ was used but allowance refresh failed, check allowance again; do not spend
68
+ another reset to refresh the display.
69
+ This uses the [documented Codex reset interface](https://learn.chatgpt.com/docs/app-server#8-earned-rate-limit-resets-chatgpt).
70
+
52
71
  ## Launch and recovery
53
72
 
54
73
  ```sh
@@ -109,6 +128,10 @@ allowance and does not fetch account allowance. `refresh` explicitly checks it.
109
128
  The menu uses one local status stream, a template icon, and native session actions.
110
129
  Opening it reads a fresh local snapshot, and visible rows update in place.
111
130
  Short-window and weekly allowance are labeled separately.
131
+ Accounts assigned to live Relay sessions have a checkmark and an “In use” label;
132
+ shared accounts also show their session count. To switch, open the Relay icon,
133
+ then your session, then **Switch account**, and choose the account email. The
134
+ current choice is marked **Current**. The menu tracks tasks started through Relay.
112
135
  Each session menu can switch to another enabled account after work and approvals
113
136
  finish. The worker checks allowance and session capacity before switching, then
114
137
  checks again that the session is idle before changing its login. Rejected actions
@@ -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.0
12
- npx --yes github:blackspider-ops/relay#v0.6.0 --version
11
+ npm install -g github:blackspider-ops/relay#v0.7.0
12
+ npx --yes github:blackspider-ops/relay#v0.7.0 --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.0",
3
+ "version": "0.7.0",
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
@@ -186,6 +186,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
186
186
  func makeMenu() -> NSMenu {
187
187
  let sessions = current["sessions"] as? [[String: Any]] ?? []
188
188
  let accounts = current["accounts"] as? [[String: Any]] ?? []
189
+ let liveSessions = sessions.filter { $0["running"] as? Bool ?? true }
189
190
  let menu = NSMenu(); menu.delegate = self
190
191
  menu.autoenablesItems = false
191
192
  text("Relay · \(sessions.count) sessions", into: menu)
@@ -195,8 +196,12 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
195
196
  if !actionRunning { action("Dismiss message", args: ["dismiss"], into: menu) }
196
197
  }
197
198
  if accounts.isEmpty { text("Add an account with relay setup in Terminal.", into: menu) }
199
+ if liveSessions.isEmpty && !accounts.isEmpty { text("No Relay sessions running. Start with relay run.", into: menu) }
198
200
  for account in accounts {
199
201
  let name = account["name"] as? String ?? "Account"
202
+ let accountID = account["id"] as? String ?? ""
203
+ let assigned = liveSessions.filter { $0["account"] as? String == accountID }
204
+ let inUse = assigned.isEmpty ? "" : assigned.count == 1 ? " · In use" : " · In use by \(assigned.count) sessions"
200
205
  let primary = account["primary"] as? [String: Any]
201
206
  let short = account["short_remaining"] as? String ?? (primary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))%" } ?? "?"
202
207
  let remaining = short == "?" ? "allowance unknown" : short + " left"
@@ -206,6 +211,13 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
206
211
  let submenu = NSMenu()
207
212
  submenu.autoenablesItems = false
208
213
  if let email = account["email"] as? String { text(email, into: submenu) }
214
+ if !assigned.isEmpty {
215
+ for session in assigned {
216
+ let sessionName = session["name"] as? String ?? String((session["id"] as? String ?? "").prefix(10))
217
+ text("In use by: \(sessionName)", into: submenu)
218
+ }
219
+ text("To switch: open that session → Switch account.", into: submenu)
220
+ }
209
221
  text("Short window: \(remaining)", into: submenu)
210
222
  if weekly != "?" { text("Weekly window: \(weekly) left", into: submenu) }
211
223
  for row in MenuController.accountTimingRows(account) {
@@ -215,12 +227,22 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
215
227
  let lastChecked = MenuController.timestamp(account["usage_checked_at"]).map { date($0) } ?? "Not checked yet"
216
228
  text("Last checked: \(lastChecked)", into: submenu)
217
229
  if let id = account["id"] as? String {
230
+ submenu.addItem(.separator())
231
+ let pendingReset = account["reset_pending"] as? Bool ?? false
232
+ let resetCount = (account["reset_credits"] as? NSNumber)?.intValue
233
+ if let count = resetCount { text("Usage resets available: \(count)", into: submenu) }
234
+ else { text("Usage resets: check allowance to see availability.", into: submenu) }
235
+ if pendingReset || (resetCount ?? 0) > 0 {
236
+ let reset = action(pendingReset ? "Check previous reset…" : "Use available reset…", args: ["confirm-reset", id, name, pendingReset ? "pending" : "new"], into: submenu)
237
+ reset.isEnabled = !actionRunning && !(account["maintenance"] as? Bool ?? false)
238
+ }
218
239
  submenu.addItem(.separator())
219
240
  let enabled = account["enabled"] as? Bool ?? true
220
241
  action(enabled ? "Disable for future routing" : "Enable for routing", args: ["account", enabled ? "disable" : "enable", id], into: submenu)
221
242
  text("Current tasks keep running.", into: submenu)
222
243
  }
223
- let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: "")
244
+ let entry = NSMenuItem(title: "\(name)\(inUse) · \(label)", action: nil, keyEquivalent: "")
245
+ entry.state = assigned.isEmpty ? .off : .on
224
246
  entry.identifier = NSUserInterfaceItemIdentifier("account:" + (account["id"] as? String ?? name))
225
247
  entry.submenu = submenu; menu.addItem(entry)
226
248
  }
@@ -248,7 +270,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
248
270
  let modelAllowed = models.isEmpty || model == nil || models.contains(model!)
249
271
  let available = (account["enabled"] as? Bool ?? true) && !(account["maintenance"] as? Bool ?? false) && modelAllowed
250
272
  let name = account["name"] as? String ?? accountID
251
- let choice = action(name + (available ? "" : " · unavailable"), args: ["switch", id, "--account", accountID, "--wait"], into: choices)
273
+ let choice = action(name + (selected ? " · Current" : available ? "" : " · unavailable"), args: ["switch", id, "--account", accountID, "--wait"], into: choices)
252
274
  choice.state = selected ? .on : .off
253
275
  choice.isEnabled = !selected && available && !actionRunning
254
276
  }
@@ -294,16 +316,29 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
294
316
  guard alert.runModal() == .alertFirstButtonReturn else { return }
295
317
  args = ["retry", args[1], "--wait"]
296
318
  }
319
+ if args[0] == "confirm-reset" {
320
+ let pending = args[3] == "pending"
321
+ let alert = NSAlert(); alert.messageText = pending ? "Check the previous reset?" : "Use one usage reset?"
322
+ 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.")
323
+ alert.addButton(withTitle: pending ? "Check reset" : "Use reset"); alert.addButton(withTitle: "Cancel")
324
+ NSApp.activate(ignoringOtherApps: true)
325
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
326
+ args = ["account", "reset", args[1], "--yes", "--request-id", UUID().uuidString]
327
+ }
297
328
  actionRunning = true; actionFailed = false; actionNotice = "Applying change…"; render()
298
- let proc = process(args); let pipe = Pipe(); proc.standardOutput = FileHandle.nullDevice; proc.standardError = pipe
329
+ let isReset = Array(args.prefix(2)) == ["account", "reset"]
330
+ let proc = process(args); let pipe = Pipe()
331
+ if isReset { proc.standardOutput = pipe } else { proc.standardOutput = FileHandle.nullDevice }
332
+ proc.standardError = pipe
299
333
  DispatchQueue.global(qos: .utility).async { [weak self] in
300
334
  do {
301
335
  try proc.run(); let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit()
302
336
  let failed = proc.terminationStatus != 0
303
337
  let detail = String(data: bytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
338
+ let resetResult = detail.split(separator: "\n").last.map(String.init) ?? "Reset check finished. Check the account's allowance."
304
339
  DispatchQueue.main.async {
305
340
  self?.actionRunning = false; self?.actionFailed = failed
306
- self?.actionNotice = failed ? (detail.isEmpty ? "Action failed. Check relay events." : detail) : MenuController.successMessage(args)
341
+ self?.actionNotice = failed ? (detail.isEmpty ? "Action failed. Check relay events." : detail) : isReset ? resetResult : MenuController.successMessage(args)
307
342
  self?.render(); self?.refreshSnapshot()
308
343
  }
309
344
  } catch { DispatchQueue.main.async { self?.actionRunning = false; self?.actionFailed = true; self?.actionNotice = "Could not run that action."; self?.render() } }
@@ -363,6 +398,27 @@ if CommandLine.arguments.contains("--self-check") {
363
398
  let switchArgs = ["switch", "live", "--account", "two", "--wait"]
364
399
  let correctChoices = findAction(switchArgs, in: controls)?.isEnabled == true && findAction(["switch", "live", "--account", "one", "--wait"], in: controls)?.state == .on && findAction(["switch", "live", "--account", "off", "--wait"], in: controls)?.isEnabled == false
365
400
  let stoppedControls = findAction(["restart", "stopped", "--if-stopped"], in: controls) != nil && findAction(["pause", "stopped", "--wait"], in: controls) == nil
401
+ let activeMarker = controls.items.first { $0.identifier?.rawValue == "account:one" }
402
+ let currentChoice = findAction(["switch", "live", "--account", "one", "--wait"], in: controls)
403
+ let correctMarker = activeMarker?.state == .on && activeMarker?.title.contains("In use") == true && currentChoice?.title.contains("Current") == true
404
+ let originalSessions = controller.current["sessions"]
405
+ controller.current["sessions"] = [["id": "a", "account": "one", "running": true], ["id": "b", "account": "one", "running": true], ["id": "c", "account": "two", "running": true], ["id": "d", "account": "off", "running": false]]
406
+ let multiple = controller.makeMenu()
407
+ let one = multiple.items.first { $0.identifier?.rawValue == "account:one" }
408
+ let two = multiple.items.first { $0.identifier?.rawValue == "account:two" }
409
+ let off = multiple.items.first { $0.identifier?.rawValue == "account:off" }
410
+ let correctMultiple = one?.title.contains("In use by 2 sessions") == true && two?.state == .on && off?.state == .off
411
+ controller.current["sessions"] = [["id": "stopped", "account": "one", "running": false]]
412
+ let empty = controller.makeMenu()
413
+ let correctEmpty = empty.items.contains { $0.title.contains("No Relay sessions running") } && empty.items.first { $0.identifier?.rawValue == "account:one" }?.state == .off
414
+ controller.current["sessions"] = originalSessions
415
+ let originalAccounts = controller.current["accounts"]
416
+ controller.current["accounts"] = [["id": "one", "name": "One", "reset_credits": 2], ["id": "two", "name": "Two", "reset_credits": 0], ["id": "unknown", "name": "Unknown"], ["id": "pending", "name": "Pending", "reset_pending": true]]
417
+ let resets = controller.makeMenu()
418
+ 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
419
+ controller.actionRunning = true
420
+ let resetLocked = findAction(["confirm-reset", "one", "One", "new"], in: controller.makeMenu())?.isEnabled == false
421
+ controller.current["accounts"] = originalAccounts
366
422
  controller.actionRunning = true; controller.actionNotice = "Applying change…"
367
423
  let pending = controller.makeMenu()
368
424
  let correctPending = findAction(switchArgs, in: pending)?.isEnabled == false && findAction(["refresh"], in: pending)?.isEnabled == false && findAction(["copy", "relay attach live"], in: pending)?.isEnabled == true
@@ -371,7 +427,9 @@ if CommandLine.arguments.contains("--self-check") {
371
427
  let retainedResult = controller.makeMenu().items.contains { $0.title == "Task is busy." }
372
428
  print("Account choices and stopped controls correct: \(correctChoices && stoppedControls)")
373
429
  print("Pending action protected and result retained: \(correctPending && retainedResult)")
374
- exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult ? 0 : 1)
430
+ print("In-use accounts and current choice marked: \(correctMarker && correctMultiple && correctEmpty)")
431
+ print("Available and pending reset actions protected: \(correctResets && resetLocked)")
432
+ exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty && correctResets && resetLocked ? 0 : 1)
375
433
  } else {
376
434
  let app = NSApplication.shared
377
435
  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,8 @@ 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),
114
117
  "maintenance": options.get("maintenance", False),
115
118
  "next_check": min(
116
119
  (b["next_check"] for b in blocks if b["account"] == label),
@@ -313,6 +316,16 @@ async def _manage(args, state, binary):
313
316
  pool = Pool(state)
314
317
  try:
315
318
  label = resolve_label(state, args.label) if args.action != "add" else args.label
319
+ if args.action == "reset":
320
+ import resets
321
+
322
+ if not args.yes:
323
+ raise RouterError(
324
+ "This uses one earned usage reset for this account. Add --yes to confirm, or use the menu's Use available reset action."
325
+ )
326
+ result = await resets.consume(state, label, binary, args.request_id)
327
+ print(resets.message(result))
328
+ return 0
316
329
  if args.action in {"enable", "disable", "priority", "cap", "rename", "models"}:
317
330
  if args.action in {"enable", "disable"}:
318
331
  pool.set_options(label, enabled=args.action == "enable")
@@ -648,6 +661,7 @@ def add_parsers(sub):
648
661
  "priority",
649
662
  "cap",
650
663
  "models",
664
+ "reset",
651
665
  ]:
652
666
  p = child.add_parser(action)
653
667
  p.add_argument("label")
@@ -661,6 +675,16 @@ def add_parsers(sub):
661
675
  p.add_argument("value", type=int)
662
676
  if action == "models":
663
677
  p.add_argument("values", nargs="*")
678
+ if action == "reset":
679
+ p.add_argument(
680
+ "--yes",
681
+ action="store_true",
682
+ help="Confirm using one earned usage reset",
683
+ )
684
+ p.add_argument(
685
+ "--request-id",
686
+ help="Optional UUID; reuse it to check the same redemption attempt",
687
+ )
664
688
  config = sub.add_parser("config", help="Set global or named recovery profiles")
665
689
  config.add_argument("key", nargs="?")
666
690
  config.add_argument("value", nargs="?")
@@ -0,0 +1,201 @@
1
+ """Explicit earned-reset redemption. Never called by automatic recovery."""
2
+
3
+ import hashlib
4
+ import json
5
+ import time
6
+ import uuid
7
+
8
+ from pool import Pool, blocked, reset_time
9
+ from settings import atomic_json
10
+
11
+ OUTCOMES = {"reset", "alreadyRedeemed", "nothingToReset", "noCredit"}
12
+
13
+
14
+ def available(snapshot):
15
+ summary = (snapshot or {}).get("rateLimitResetCredits")
16
+ count = summary.get("availableCount") if isinstance(summary, dict) else None
17
+ return count if type(count) is int and count >= 0 else None
18
+
19
+
20
+ def identity(home):
21
+ marker = (home / "enrolled").read_bytes()
22
+ return json.loads(marker).get("identity") or hashlib.sha256(marker).hexdigest()
23
+
24
+
25
+ def receipt(home):
26
+ path = home / "reset-attempt.json"
27
+ if not path.exists():
28
+ return {}
29
+ value = json.loads(path.read_text())
30
+ if not isinstance(value, dict):
31
+ raise ValueError(
32
+ "Unreadable reset receipt. Check this account before trying again."
33
+ )
34
+ return value
35
+
36
+
37
+ def pending(home):
38
+ try:
39
+ value = receipt(home)
40
+ return value.get("state") == "pending" and value.get("identity") == identity(
41
+ home
42
+ )
43
+ except (OSError, ValueError):
44
+ return False
45
+
46
+
47
+ def update_allowance(pool, label, snapshot):
48
+ """Only verified allowance can lift a quota block. Keep other retry reasons."""
49
+ if not isinstance(snapshot, dict) or not (
50
+ isinstance(snapshot.get("rateLimits"), dict)
51
+ or isinstance(snapshot.get("rateLimitsByLimitId"), dict)
52
+ or type(snapshot.get("ordinaryUsageAllowed")) is bool
53
+ ):
54
+ raise ValueError(
55
+ "Codex returned unrecognized allowance data; quota blocks were retained."
56
+ )
57
+ pool.save(label, snapshot)
58
+ with pool.db() as db:
59
+ scopes = [
60
+ r[0]
61
+ for r in db.execute(
62
+ "SELECT scope FROM blocks WHERE label=? AND reason='quota'", (label,)
63
+ )
64
+ ]
65
+ for scope in scopes:
66
+ model = None if scope == "*" else scope
67
+ if blocked(snapshot, model):
68
+ until = reset_time(snapshot, model) or time.time() + 300
69
+ db.execute(
70
+ "UPDATE blocks SET until=? WHERE label=? AND scope=? AND reason='quota'",
71
+ (until, label, scope),
72
+ )
73
+ else:
74
+ db.execute(
75
+ "DELETE FROM blocks WHERE label=? AND scope=? AND reason='quota'",
76
+ (label, scope),
77
+ )
78
+ pool.signal()
79
+
80
+
81
+ async def consume(state, label, binary, request_id=None):
82
+ from router import Account, RouterError, RemoteError, account_home, labels
83
+
84
+ if label not in labels(state):
85
+ raise RouterError("Choose an enrolled account from relay list.")
86
+ if request_id is not None:
87
+ try:
88
+ request_id = str(uuid.UUID(request_id))
89
+ except (ValueError, AttributeError):
90
+ raise RouterError("Use a UUID for --request-id.") from None
91
+ home = account_home(state, label)
92
+ pool = Pool(state)
93
+ account = Account(label, home, binary, pool)
94
+ try:
95
+ # The same per-account credential lock is used by old and new brokers.
96
+ # A reset does not require replacing the coordinator or a task's login.
97
+ async with account.operation() as rpc:
98
+ signed_in = identity(home)
99
+ previous = receipt(home)
100
+ same_identity = previous.get("identity") == signed_in
101
+ retry = same_identity and previous.get("state") == "pending"
102
+ known = (
103
+ same_identity
104
+ and request_id is not None
105
+ and previous.get("request_id") == request_id
106
+ and previous.get("state") == "done"
107
+ )
108
+ key = (
109
+ previous.get("request_id") if retry else request_id or str(uuid.uuid4())
110
+ )
111
+ if retry:
112
+ try:
113
+ key = str(uuid.UUID(key))
114
+ except (ValueError, AttributeError, TypeError):
115
+ raise RouterError(
116
+ "The pending reset receipt is unreadable. Check account usage before trying again."
117
+ ) from None
118
+ outcome = previous.get("outcome") if known else None
119
+ if known and (not isinstance(outcome, str) or outcome not in OUTCOMES):
120
+ raise RouterError(
121
+ "The saved reset result is unreadable. Check account usage before trying again."
122
+ )
123
+ if known and outcome == "reset":
124
+ outcome = "alreadyRedeemed"
125
+ if not retry and not known:
126
+ current = await rpc.call("account/rateLimits/read")
127
+ update_allowance(pool, label, current)
128
+ count = available(current)
129
+ if count == 0:
130
+ return {"outcome": "noCredit", "refreshed": True}
131
+ if count is None:
132
+ raise RouterError(
133
+ "Codex did not report reset availability. Check allowance again before using a reset."
134
+ )
135
+ if not known:
136
+ attempt = {
137
+ "request_id": key,
138
+ "identity": signed_in,
139
+ "state": "pending",
140
+ "at": time.time(),
141
+ }
142
+ atomic_json(home / "reset-attempt.json", attempt)
143
+ pool.invalidate(label)
144
+ pool.signal()
145
+ try:
146
+ response = await rpc.call(
147
+ "account/rateLimitResetCredit/consume", {"idempotencyKey": key}
148
+ )
149
+ outcome = response.get("outcome")
150
+ except RemoteError as error:
151
+ if error.payload.get("code") in {-32601, -32602}:
152
+ atomic_json(
153
+ home / "reset-attempt.json",
154
+ {**attempt, "state": "rejected"},
155
+ )
156
+ raise RouterError(
157
+ "This Codex runtime cannot use resets through Relay. Update Codex and try again."
158
+ ) from None
159
+ raise RouterError(
160
+ "Reset result could not be confirmed. Try this action again; Relay will reuse the same pending request."
161
+ ) from None
162
+ except Exception:
163
+ raise RouterError(
164
+ "Reset result could not be confirmed. Try this action again; Relay will reuse the same pending request."
165
+ ) from None
166
+ if not isinstance(outcome, str) or outcome not in OUTCOMES:
167
+ raise RouterError(
168
+ "Reset result was not recognized. Try again to check the same pending request."
169
+ )
170
+ atomic_json(
171
+ home / "reset-attempt.json",
172
+ {**attempt, "state": "done", "outcome": outcome},
173
+ )
174
+ refreshed = False
175
+ try:
176
+ snapshot = await rpc.call("account/rateLimits/read")
177
+ update_allowance(pool, label, snapshot)
178
+ refreshed = True
179
+ except Exception:
180
+ pool.invalidate(label)
181
+ pool.signal()
182
+ pool.record(
183
+ "reset_checked", account=label, outcome=outcome, refreshed=refreshed
184
+ )
185
+ return {"outcome": outcome, "refreshed": refreshed}
186
+ finally:
187
+ pool.close()
188
+
189
+
190
+ def message(result):
191
+ text = {
192
+ "reset": "Used one usage reset.",
193
+ "alreadyRedeemed": "The previous reset was already used; no additional reset was spent.",
194
+ "nothingToReset": "No usage window is eligible for a reset; no reset was used.",
195
+ "noCredit": "No usage resets are available; no reset was used.",
196
+ }[result["outcome"]]
197
+ return text + (
198
+ " Allowance updated."
199
+ if result["refreshed"]
200
+ else " Allowance could not be refreshed; choose Check allowance now."
201
+ )
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.0"
27
+ VERSION = "0.7.0"
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,32 @@ 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))
129
130
  result = {
130
131
  "ordinaryUsageAllowed": True,
131
132
  "rateLimits": {
132
133
  "primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600}
133
134
  },
135
+ "rateLimitResetCredits": {
136
+ "availableCount": reset_count,
137
+ "credits": None,
138
+ },
134
139
  }
140
+ elif method == "account/rateLimitResetCredit/consume":
141
+
142
+ def redeem(data):
143
+ keys = data.setdefault("reset_keys", [])
144
+ key = params["idempotencyKey"]
145
+ if key in keys:
146
+ return {"outcome": "alreadyRedeemed"}
147
+ count = data.get("reset_credit_count", 1)
148
+ if count <= 0:
149
+ return {"outcome": "noCredit"}
150
+ keys.append(key)
151
+ data["reset_credit_count"] = count - 1
152
+ return {"outcome": "reset"}
153
+
154
+ result = change(redeem)
135
155
  elif method in ("thread/start", "thread/fork"):
136
156
  thread = str(uuid.uuid4())
137
157
  value = {
@@ -0,0 +1,227 @@
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
+
31
+ async def call(self, method, params=None):
32
+ await asyncio.sleep(0)
33
+ if method == "account/rateLimits/read":
34
+ if self.fail_refresh and self.completed:
35
+ raise asyncio.TimeoutError()
36
+ return {
37
+ "ordinaryUsageAllowed": not self.blocked,
38
+ "rateLimits": {
39
+ "primary": {
40
+ "usedPercent": 100 if self.blocked else 0,
41
+ "resetsAt": time.time() + 3600,
42
+ }
43
+ },
44
+ "rateLimitResetCredits": {
45
+ "availableCount": self.count,
46
+ "credits": None,
47
+ },
48
+ }
49
+ if method != "account/rateLimitResetCredit/consume":
50
+ raise AssertionError(method)
51
+ if self.reject:
52
+ raise RemoteError({"code": -32601})
53
+ key = params["idempotencyKey"]
54
+ self.calls.append(key)
55
+ if key in self.completed:
56
+ return {"outcome": "alreadyRedeemed"}
57
+ if self.outcome in {"noCredit", "nothingToReset"}:
58
+ if self.outcome == "noCredit":
59
+ self.count = 0
60
+ return {"outcome": self.outcome}
61
+ self.completed.add(key)
62
+ self.count -= 1
63
+ self.blocked = self.residual_limit
64
+ if self.lose_reply:
65
+ self.lose_reply = False
66
+ raise asyncio.TimeoutError()
67
+ return {"outcome": self.outcome or "reset"}
68
+
69
+
70
+ class ResetTests(unittest.IsolatedAsyncioTestCase):
71
+ async def asyncSetUp(self):
72
+ self.tmp = tempfile.TemporaryDirectory(prefix="relay-reset-test-")
73
+ self.root = Path(self.tmp.name)
74
+ self.home = self.root / "accounts" / "one"
75
+ self.home.mkdir(parents=True)
76
+ atomic_json(self.home / "enrolled", {"identity": "fake-account-A"})
77
+ self.pool = Pool(self.root)
78
+ self.service = ResetService()
79
+ service = self.service
80
+
81
+ class TestAccount(Account):
82
+ async def start(self):
83
+ self.rpc = service
84
+
85
+ async def close(self):
86
+ self.rpc = None
87
+
88
+ self.patch = patch("router.Account", TestAccount)
89
+ self.patch.start()
90
+
91
+ async def asyncTearDown(self):
92
+ self.patch.stop()
93
+ self.pool.close()
94
+ self.tmp.cleanup()
95
+
96
+ async def test_success_refreshes_allowance_and_only_lifts_verified_quota_blocks(
97
+ self,
98
+ ):
99
+ self.pool.mark("one", reason="quota")
100
+ self.pool.mark("one", "other-model", reason="auth")
101
+ self.pool.intent("thread", {"review_required": True})
102
+ self.pool.touch(paused=True)
103
+ result = await resets.consume(self.root, "one", "fake")
104
+ self.assertEqual(result, {"outcome": "reset", "refreshed": True})
105
+ self.assertEqual(resets.available(self.pool.cached("one")), 1)
106
+ with self.pool.db() as db:
107
+ self.assertEqual(
108
+ db.execute("SELECT scope,reason FROM blocks").fetchall(),
109
+ [("other-model", "auth")],
110
+ )
111
+ details = json.loads(
112
+ db.execute(
113
+ "SELECT details FROM session_details WHERE id=?",
114
+ (self.pool.session,),
115
+ ).fetchone()[0]
116
+ )
117
+ self.assertTrue(details["paused"])
118
+ self.assertTrue(self.pool.intent("thread")["review_required"])
119
+ self.assertEqual(
120
+ (self.home / "reset-attempt.json").stat().st_mode & 0o777, 0o600
121
+ )
122
+
123
+ async def test_lost_reply_reuses_pending_key_even_if_next_click_has_a_new_key(self):
124
+ self.service.lose_reply = True
125
+ with self.assertRaisesRegex(RouterError, "same pending request"):
126
+ await resets.consume(self.root, "one", "fake", str(uuid.uuid4()))
127
+ self.assertTrue(resets.pending(self.home))
128
+ account = commands.snapshot(self.root)["accounts"][0]
129
+ self.assertTrue(account["reset_pending"])
130
+ self.assertIsNone(account["reset_credits"])
131
+ result = await resets.consume(self.root, "one", "fake", str(uuid.uuid4()))
132
+ self.assertEqual(result["outcome"], "alreadyRedeemed")
133
+ self.assertEqual(len(set(self.service.calls)), 1)
134
+ self.assertEqual(self.service.count, 1)
135
+ self.assertFalse(resets.pending(self.home))
136
+
137
+ async def test_concurrent_duplicate_request_is_redeemed_once(self):
138
+ key = str(uuid.uuid4())
139
+ results = await asyncio.gather(
140
+ *(resets.consume(self.root, "one", "fake", key) for _ in range(2))
141
+ )
142
+ self.assertEqual({r["outcome"] for r in results}, {"reset", "alreadyRedeemed"})
143
+ self.assertEqual(self.service.calls, [key])
144
+
145
+ async def test_success_with_failed_refresh_does_not_claim_updated_limits(self):
146
+ self.service.fail_refresh = True
147
+ key = str(uuid.uuid4())
148
+ self.pool.mark("one", reason="quota")
149
+ result = await resets.consume(self.root, "one", "fake", key)
150
+ self.assertFalse(result["refreshed"])
151
+ self.assertIn("could not be refreshed", resets.message(result))
152
+ self.assertIsNone(self.pool.cached("one"))
153
+ self.assertGreater(self.pool.deadline("one"), time.time())
154
+ retry = await resets.consume(self.root, "one", "fake", key)
155
+ self.assertEqual(retry["outcome"], "alreadyRedeemed")
156
+ self.assertEqual(len(self.service.calls), 1)
157
+
158
+ async def test_remaining_limit_is_retained_after_a_reset(self):
159
+ self.service.residual_limit = True
160
+ self.pool.mark("one", reason="quota")
161
+ await resets.consume(self.root, "one", "fake")
162
+ self.assertGreater(self.pool.deadline("one"), time.time())
163
+
164
+ async def test_no_credit_or_nothing_to_reset_are_reported_without_success_claim(
165
+ self,
166
+ ):
167
+ for outcome in ("noCredit", "nothingToReset"):
168
+ self.service.outcome = outcome
169
+ self.service.count = 2
170
+ result = await resets.consume(self.root, "one", "fake")
171
+ self.assertEqual(result["outcome"], outcome)
172
+ self.assertIn("no reset was used", resets.message(result))
173
+ self.service.count = 0
174
+ self.service.calls.clear()
175
+ result = await resets.consume(self.root, "one", "fake")
176
+ self.assertEqual(result["outcome"], "noCredit")
177
+ self.assertFalse(self.service.calls)
178
+
179
+ async def test_unknown_outcome_remains_pending_and_unsupported_method_is_actionable(
180
+ self,
181
+ ):
182
+ self.service.outcome = "newUnknownOutcome"
183
+ with self.assertRaisesRegex(RouterError, "not recognized"):
184
+ await resets.consume(self.root, "one", "fake")
185
+ self.assertTrue(resets.pending(self.home))
186
+ self.service.reject = True
187
+ with self.assertRaisesRegex(RouterError, "Update Codex"):
188
+ await resets.consume(self.root, "one", "fake")
189
+ self.assertFalse(resets.pending(self.home))
190
+
191
+ async def test_cli_requires_confirmation_before_starting_redemption(self):
192
+ args = parse_arguments(["account", "reset", "one"])
193
+ with patch("resets.consume", side_effect=AssertionError("Unconfirmed reset")):
194
+ with self.assertRaisesRegex(RouterError, "--yes"):
195
+ await commands.manage(args, self.root, "fake")
196
+
197
+ async def test_real_jsonl_helper_redeems_and_refreshes_synthetic_account(self):
198
+ from test_services import fake_executable
199
+
200
+ self.patch.stop()
201
+ binary = fake_executable(self.root)
202
+ result = await resets.consume(self.root, "one", binary)
203
+ self.assertEqual(result, {"outcome": "reset", "refreshed": True})
204
+ history = json.loads((self.home / "fake-history.json").read_text())
205
+ self.assertEqual(len(history["reset_keys"]), 1)
206
+ self.assertEqual(history["reset_credit_count"], 0)
207
+ self.assertEqual(
208
+ sum(c["method"] == "account/rateLimits/read" for c in history["calls"]), 2
209
+ )
210
+
211
+ def test_available_count_is_authoritative_and_invalid_values_stay_unknown(self):
212
+ self.assertEqual(
213
+ resets.available(
214
+ {"rateLimitResetCredits": {"availableCount": 3, "credits": []}}
215
+ ),
216
+ 3,
217
+ )
218
+ for count in (-1, True, "2", 1.5, None):
219
+ self.assertIsNone(
220
+ resets.available({"rateLimitResetCredits": {"availableCount": count}})
221
+ )
222
+ with self.assertRaises(ValueError):
223
+ resets.update_allowance(self.pool, "one", {})
224
+
225
+
226
+ if __name__ == "__main__":
227
+ unittest.main()