relay-codex 0.6.1 → 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
@@ -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.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.1",
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
@@ -227,6 +227,15 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
227
227
  let lastChecked = MenuController.timestamp(account["usage_checked_at"]).map { date($0) } ?? "Not checked yet"
228
228
  text("Last checked: \(lastChecked)", into: submenu)
229
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
+ }
230
239
  submenu.addItem(.separator())
231
240
  let enabled = account["enabled"] as? Bool ?? true
232
241
  action(enabled ? "Disable for future routing" : "Enable for routing", args: ["account", enabled ? "disable" : "enable", id], into: submenu)
@@ -307,16 +316,29 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
307
316
  guard alert.runModal() == .alertFirstButtonReturn else { return }
308
317
  args = ["retry", args[1], "--wait"]
309
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
+ }
310
328
  actionRunning = true; actionFailed = false; actionNotice = "Applying change…"; render()
311
- 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
312
333
  DispatchQueue.global(qos: .utility).async { [weak self] in
313
334
  do {
314
335
  try proc.run(); let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit()
315
336
  let failed = proc.terminationStatus != 0
316
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."
317
339
  DispatchQueue.main.async {
318
340
  self?.actionRunning = false; self?.actionFailed = failed
319
- 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)
320
342
  self?.render(); self?.refreshSnapshot()
321
343
  }
322
344
  } catch { DispatchQueue.main.async { self?.actionRunning = false; self?.actionFailed = true; self?.actionNotice = "Could not run that action."; self?.render() } }
@@ -390,6 +412,13 @@ if CommandLine.arguments.contains("--self-check") {
390
412
  let empty = controller.makeMenu()
391
413
  let correctEmpty = empty.items.contains { $0.title.contains("No Relay sessions running") } && empty.items.first { $0.identifier?.rawValue == "account:one" }?.state == .off
392
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
393
422
  controller.actionRunning = true; controller.actionNotice = "Applying change…"
394
423
  let pending = controller.makeMenu()
395
424
  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 +428,8 @@ if CommandLine.arguments.contains("--self-check") {
399
428
  print("Account choices and stopped controls correct: \(correctChoices && stoppedControls)")
400
429
  print("Pending action protected and result retained: \(correctPending && retainedResult)")
401
430
  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)
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)
403
433
  } else {
404
434
  let app = NSApplication.shared
405
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.1"
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()