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