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 +3 -0
- package/docs/commands.md +19 -0
- package/docs/publishing.md +2 -2
- package/package.json +1 -1
- package/python/Menu.swift +33 -3
- package/python/commands.py +24 -0
- package/python/resets.py +201 -0
- package/python/router.py +2 -2
- package/tests/fake_native.py +20 -0
- package/tests/test_resets.py +227 -0
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
|
package/docs/publishing.md
CHANGED
|
@@ -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.
|
|
12
|
-
npx --yes github:blackspider-ops/relay#v0.
|
|
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
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
|
|
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
|
-
|
|
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()
|
package/python/commands.py
CHANGED
|
@@ -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="?")
|
package/python/resets.py
ADDED
|
@@ -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.
|
|
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
|
package/tests/fake_native.py
CHANGED
|
@@ -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()
|