relay-codex 0.7.0 → 0.7.2
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 +2 -1
- package/docs/commands.md +9 -3
- package/docs/publishing.md +2 -2
- package/package.json +1 -1
- package/python/Menu.swift +88 -7
- package/python/commands.py +1 -0
- package/python/resets.py +30 -3
- package/python/router.py +1 -1
- package/tests/fake_native.py +14 -1
- package/tests/test_resets.py +63 -1
package/README.md
CHANGED
|
@@ -69,7 +69,8 @@ Use a session's menu to switch accounts after its work finishes. Account menus c
|
|
|
69
69
|
enable or disable future routing without stopping current tasks. Actions show
|
|
70
70
|
their result, and stopped workers offer a restart.
|
|
71
71
|
Account menus also show earned usage resets when available. Choose **Use available
|
|
72
|
-
reset…** and confirm to spend one; Relay refreshes allowance afterward.
|
|
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
|
|
73
74
|
never spends these resets automatically.
|
|
74
75
|
|
|
75
76
|
For tab completion in Zsh, run `source <(relay completion zsh)` after `compinit`.
|
package/docs/commands.md
CHANGED
|
@@ -51,13 +51,17 @@ A session cap of zero means unlimited. Removing an account retains its login
|
|
|
51
51
|
unless you request `--revoke`.
|
|
52
52
|
|
|
53
53
|
The account menu shows **Usage resets available** and **Use available reset…**
|
|
54
|
-
when Codex reports an earned 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
|
|
55
58
|
is unknown, choose **Check allowance now** first. Reset availability is separate
|
|
56
59
|
from purchased workspace credits. Resets are never spent by automatic recovery.
|
|
57
60
|
|
|
58
61
|
The equivalent terminal command is `relay account reset ACCOUNT --yes`. If a
|
|
59
62
|
response is lost, use the same action again: Relay saves and reuses the pending
|
|
60
|
-
request rather than submitting a new redemption.
|
|
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
|
|
61
65
|
previous reset…**. An optional `--request-id UUID` also identifies a specific
|
|
62
66
|
logical attempt. Codex chooses the available reset; Relay checks the returned
|
|
63
67
|
result and reads allowance again afterward.
|
|
@@ -136,7 +140,9 @@ Each session menu can switch to another enabled account after work and approvals
|
|
|
136
140
|
finish. The worker checks allowance and session capacity before switching, then
|
|
137
141
|
checks again that the session is idle before changing its login. Rejected actions
|
|
138
142
|
show their reason. Pending menu actions cannot be submitted twice, and results
|
|
139
|
-
|
|
143
|
+
disappear 30 seconds after first being viewed in the menu. Unseen results wait
|
|
144
|
+
until the menu is opened; action progress remains until the action finishes.
|
|
145
|
+
Starting another action replaces its previous result. Stopped workers offer
|
|
140
146
|
Restart; restarting retains any review hold.
|
|
141
147
|
|
|
142
148
|
Account menus can disable or enable future routing. Disabling an account does not
|
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.7.
|
|
12
|
-
npx --yes github:blackspider-ops/relay#v0.7.
|
|
11
|
+
npm install -g github:blackspider-ops/relay#v0.7.2
|
|
12
|
+
npx --yes github:blackspider-ops/relay#v0.7.2 --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
|
@@ -6,12 +6,18 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
6
6
|
var buffer = Data()
|
|
7
7
|
var current: [String: Any] = [:]
|
|
8
8
|
var message: String?
|
|
9
|
-
var actionNotice: String?
|
|
9
|
+
var actionNotice: String? {
|
|
10
|
+
didSet { noticeVersion += 1; noticeExpiry?.cancel(); noticeExpiry = nil }
|
|
11
|
+
}
|
|
12
|
+
var noticeVersion = 0
|
|
13
|
+
var noticeExpiry: DispatchWorkItem?
|
|
10
14
|
var actionFailed = false
|
|
11
15
|
var actionRunning = false
|
|
12
16
|
var reconnect: DispatchWorkItem?
|
|
13
17
|
var stopping = false
|
|
14
18
|
var snapshotInFlight = false
|
|
19
|
+
var resetExpiry: DispatchWorkItem?
|
|
20
|
+
var menuOpen = false
|
|
15
21
|
let command = Bundle.main.object(forInfoDictionaryKey: "RelayCommand") as? String ?? "/usr/bin/false"
|
|
16
22
|
let state = Bundle.main.object(forInfoDictionaryKey: "RelayState") as? String ?? ""
|
|
17
23
|
|
|
@@ -140,6 +146,15 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
140
146
|
guard let seconds = value as? Double, seconds.isFinite, seconds > 0 else { return nil }
|
|
141
147
|
return seconds
|
|
142
148
|
}
|
|
149
|
+
static func resetBlockReason(_ account: [String: Any], now: Double = Date().timeIntervalSince1970) -> String? {
|
|
150
|
+
guard let weekly = account["secondary"] as? [String: Any], let used = (weekly["usedPercent"] as? NSNumber)?.doubleValue, used.isFinite else {
|
|
151
|
+
return "Reset locked: check allowance to confirm the weekly limit and reset time."
|
|
152
|
+
}
|
|
153
|
+
guard used >= 100 else { return "Reset locked: weekly allowance must be fully exhausted (0%)." }
|
|
154
|
+
guard let at = timestamp(weekly["resetsAt"]) else { return "Reset locked: check allowance to confirm the weekly limit and reset time." }
|
|
155
|
+
guard at - now > 48 * 60 * 60 else { return "Reset locked: the weekly reset must be more than 48 hours away." }
|
|
156
|
+
return nil
|
|
157
|
+
}
|
|
143
158
|
static func accountTimingRows(_ account: [String: Any]) -> [TimingRow] {
|
|
144
159
|
var rows: [TimingRow] = []
|
|
145
160
|
if let primary = account["primary"] as? [String: Any], let at = timestamp(primary["resetsAt"]) {
|
|
@@ -182,6 +197,8 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
182
197
|
updateTimes(menu)
|
|
183
198
|
if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
|
|
184
199
|
else { item.menu = menu }
|
|
200
|
+
if menuOpen { scheduleResetExpiry() }
|
|
201
|
+
scheduleNoticeExpiry()
|
|
185
202
|
}
|
|
186
203
|
func makeMenu() -> NSMenu {
|
|
187
204
|
let sessions = current["sessions"] as? [[String: Any]] ?? []
|
|
@@ -193,7 +210,6 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
193
210
|
if let message = message { text(message, into: menu) }
|
|
194
211
|
if let notice = actionNotice {
|
|
195
212
|
text(notice, into: menu)
|
|
196
|
-
if !actionRunning { action("Dismiss message", args: ["dismiss"], into: menu) }
|
|
197
213
|
}
|
|
198
214
|
if accounts.isEmpty { text("Add an account with relay setup in Terminal.", into: menu) }
|
|
199
215
|
if liveSessions.isEmpty && !accounts.isEmpty { text("No Relay sessions running. Start with relay run.", into: menu) }
|
|
@@ -233,8 +249,10 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
233
249
|
if let count = resetCount { text("Usage resets available: \(count)", into: submenu) }
|
|
234
250
|
else { text("Usage resets: check allowance to see availability.", into: submenu) }
|
|
235
251
|
if pendingReset || (resetCount ?? 0) > 0 {
|
|
252
|
+
let blocked = MenuController.resetBlockReason(account)
|
|
236
253
|
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)
|
|
254
|
+
reset.isEnabled = !actionRunning && !(account["maintenance"] as? Bool ?? false) && blocked == nil
|
|
255
|
+
if let blocked = blocked { text(blocked, into: submenu) }
|
|
238
256
|
}
|
|
239
257
|
submenu.addItem(.separator())
|
|
240
258
|
let enabled = account["enabled"] as? Bool ?? true
|
|
@@ -298,16 +316,55 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
298
316
|
return menu
|
|
299
317
|
}
|
|
300
318
|
func menuWillOpen(_ menu: NSMenu) {
|
|
319
|
+
menuOpen = true
|
|
320
|
+
render()
|
|
301
321
|
refreshSnapshot()
|
|
302
322
|
updateTimes(menu)
|
|
303
323
|
for entry in menu.items where entry.title == "Open at login" {
|
|
304
324
|
entry.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
|
|
305
325
|
}
|
|
306
326
|
}
|
|
327
|
+
func scheduleResetExpiry() {
|
|
328
|
+
resetExpiry?.cancel()
|
|
329
|
+
let now = Date().timeIntervalSince1970
|
|
330
|
+
let accounts = current["accounts"] as? [[String: Any]] ?? []
|
|
331
|
+
let boundaries = accounts.compactMap { account -> Double? in
|
|
332
|
+
guard MenuController.resetBlockReason(account, now: now) == nil, let weekly = account["secondary"] as? [String: Any], let at = MenuController.timestamp(weekly["resetsAt"]) else { return nil }
|
|
333
|
+
return at - 48 * 60 * 60
|
|
334
|
+
}
|
|
335
|
+
if let boundary = boundaries.min() {
|
|
336
|
+
let expiry = DispatchWorkItem { [weak self] in self?.render() }
|
|
337
|
+
resetExpiry = expiry
|
|
338
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + max(0, boundary - now), execute: expiry)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
func menuDidClose(_ menu: NSMenu) { menuOpen = false; resetExpiry?.cancel(); resetExpiry = nil }
|
|
342
|
+
func scheduleNoticeExpiry() {
|
|
343
|
+
guard menuOpen, !actionRunning, actionNotice != nil, noticeExpiry == nil else { return }
|
|
344
|
+
let version = noticeVersion
|
|
345
|
+
let expiry = DispatchWorkItem { [weak self] in
|
|
346
|
+
guard let self = self, self.expireNotice(version: version) else { return }
|
|
347
|
+
self.render()
|
|
348
|
+
}
|
|
349
|
+
noticeExpiry = expiry
|
|
350
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: expiry)
|
|
351
|
+
}
|
|
352
|
+
func expireNotice(version: Int) -> Bool {
|
|
353
|
+
guard !stopping, !actionRunning, actionNotice != nil, noticeVersion == version else { return false }
|
|
354
|
+
actionNotice = nil; actionFailed = false
|
|
355
|
+
return true
|
|
356
|
+
}
|
|
357
|
+
func resetAllowed(_ id: String) -> Bool {
|
|
358
|
+
let accounts = current["accounts"] as? [[String: Any]] ?? []
|
|
359
|
+
guard let account = accounts.first(where: { $0["id"] as? String == id }) else {
|
|
360
|
+
actionNotice = "Reset locked: account details are unavailable."; actionFailed = true; render(); return false
|
|
361
|
+
}
|
|
362
|
+
if let reason = MenuController.resetBlockReason(account) { actionNotice = reason; actionFailed = true; render(); return false }
|
|
363
|
+
return true
|
|
364
|
+
}
|
|
307
365
|
@objc func runAction(_ sender: NSMenuItem) {
|
|
308
366
|
guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
|
|
309
367
|
if args[0] == "copy" { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args[1], forType: .string); return }
|
|
310
|
-
if args[0] == "dismiss" { actionNotice = nil; actionFailed = false; render(); return }
|
|
311
368
|
guard !actionRunning else { return }
|
|
312
369
|
if args[0] == "confirm-retry" {
|
|
313
370
|
let alert = NSAlert(); alert.messageText = "Continue this task?"; alert.informativeText = "Review any interrupted command or external action in the conversation before continuing."
|
|
@@ -317,12 +374,14 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
317
374
|
args = ["retry", args[1], "--wait"]
|
|
318
375
|
}
|
|
319
376
|
if args[0] == "confirm-reset" {
|
|
377
|
+
guard resetAllowed(args[1]) else { return }
|
|
320
378
|
let pending = args[3] == "pending"
|
|
321
379
|
let alert = NSAlert(); alert.messageText = pending ? "Check the previous reset?" : "Use one usage reset?"
|
|
322
380
|
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
381
|
alert.addButton(withTitle: pending ? "Check reset" : "Use reset"); alert.addButton(withTitle: "Cancel")
|
|
324
382
|
NSApp.activate(ignoringOtherApps: true)
|
|
325
383
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
384
|
+
guard resetAllowed(args[1]) else { return }
|
|
326
385
|
args = ["account", "reset", args[1], "--yes", "--request-id", UUID().uuidString]
|
|
327
386
|
}
|
|
328
387
|
actionRunning = true; actionFailed = false; actionNotice = "Applying change…"; render()
|
|
@@ -363,7 +422,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
363
422
|
runAction(sender)
|
|
364
423
|
}
|
|
365
424
|
@objc func quitMenu() { stopping = true; reconnect?.cancel(); stream?.terminate(); NSApp.terminate(nil) }
|
|
366
|
-
func applicationWillTerminate(_ notification: Notification) { stopping = true; stream?.terminate() }
|
|
425
|
+
func applicationWillTerminate(_ notification: Notification) { stopping = true; noticeExpiry?.cancel(); resetExpiry?.cancel(); stream?.terminate() }
|
|
367
426
|
}
|
|
368
427
|
if CommandLine.arguments.contains("--self-check") {
|
|
369
428
|
let normal = NSImage(systemSymbolName: "arrow.triangle.branch", accessibilityDescription: "Relay")
|
|
@@ -413,11 +472,18 @@ if CommandLine.arguments.contains("--self-check") {
|
|
|
413
472
|
let correctEmpty = empty.items.contains { $0.title.contains("No Relay sessions running") } && empty.items.first { $0.identifier?.rawValue == "account:one" }?.state == .off
|
|
414
473
|
controller.current["sessions"] = originalSessions
|
|
415
474
|
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]]
|
|
475
|
+
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]]
|
|
417
476
|
let resets = controller.makeMenu()
|
|
418
477
|
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
478
|
controller.actionRunning = true
|
|
420
479
|
let resetLocked = findAction(["confirm-reset", "one", "One", "new"], in: controller.makeMenu())?.isEnabled == false
|
|
480
|
+
let policyNow = 2000000000.0
|
|
481
|
+
let far: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172801]]
|
|
482
|
+
let exact: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172800]]
|
|
483
|
+
let near: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172799]]
|
|
484
|
+
let left: [String: Any] = ["secondary": ["usedPercent": 99.99, "resetsAt": policyNow + 259200]]
|
|
485
|
+
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
|
|
486
|
+
let pendingLocked = findAction(["confirm-reset", "pending", "Pending", "pending"], in: resets)?.isEnabled == false
|
|
421
487
|
controller.current["accounts"] = originalAccounts
|
|
422
488
|
controller.actionRunning = true; controller.actionNotice = "Applying change…"
|
|
423
489
|
let pending = controller.makeMenu()
|
|
@@ -425,11 +491,26 @@ if CommandLine.arguments.contains("--self-check") {
|
|
|
425
491
|
controller.actionRunning = false; controller.actionNotice = "Task is busy."
|
|
426
492
|
controller.current["at"] = 2000000001.0
|
|
427
493
|
let retainedResult = controller.makeMenu().items.contains { $0.title == "Task is busy." }
|
|
494
|
+
let noDismissButton = findAction(["dismiss"], in: controller.makeMenu()) == nil
|
|
495
|
+
controller.menuOpen = false; controller.actionFailed = true; controller.actionNotice = "Unseen result"
|
|
496
|
+
controller.scheduleNoticeExpiry()
|
|
497
|
+
let unseenRetained = controller.noticeExpiry == nil && controller.actionNotice == "Unseen result"
|
|
498
|
+
controller.menuOpen = true; controller.scheduleNoticeExpiry()
|
|
499
|
+
let startsOnView = controller.noticeExpiry != nil
|
|
500
|
+
let previousNotice = controller.noticeVersion
|
|
501
|
+
controller.actionNotice = "Newer result"
|
|
502
|
+
let newerProtected = !controller.expireNotice(version: previousNotice) && controller.actionNotice == "Newer result"
|
|
503
|
+
controller.actionRunning = true; controller.scheduleNoticeExpiry()
|
|
504
|
+
let progressRetained = controller.noticeExpiry == nil && !controller.expireNotice(version: controller.noticeVersion)
|
|
505
|
+
controller.actionRunning = false
|
|
506
|
+
let expiresAfterView = controller.expireNotice(version: controller.noticeVersion) && controller.actionNotice == nil && !controller.actionFailed
|
|
428
507
|
print("Account choices and stopped controls correct: \(correctChoices && stoppedControls)")
|
|
429
508
|
print("Pending action protected and result retained: \(correctPending && retainedResult)")
|
|
430
509
|
print("In-use accounts and current choice marked: \(correctMarker && correctMultiple && correctEmpty)")
|
|
431
510
|
print("Available and pending reset actions protected: \(correctResets && resetLocked)")
|
|
432
|
-
|
|
511
|
+
print("Weekly zero and more-than-48-hour guard enforced: \(correctPolicy && pendingLocked)")
|
|
512
|
+
print("Viewed notices expire without hiding unseen or newer results: \(noDismissButton && unseenRetained && startsOnView && newerProtected && progressRetained && expiresAfterView)")
|
|
513
|
+
exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty && correctResets && resetLocked && correctPolicy && pendingLocked && noDismissButton && unseenRetained && startsOnView && newerProtected && progressRetained && expiresAfterView ? 0 : 1)
|
|
433
514
|
} else {
|
|
434
515
|
let app = NSApplication.shared
|
|
435
516
|
let delegate = MenuController(); app.delegate = delegate; app.run()
|
package/python/commands.py
CHANGED
|
@@ -114,6 +114,7 @@ def snapshot(state, include_inactive=False):
|
|
|
114
114
|
"usage_checked_at": pool.checked_at(label),
|
|
115
115
|
"reset_credits": resets.available(usage),
|
|
116
116
|
"reset_pending": resets.pending(state / "accounts" / label),
|
|
117
|
+
"reset_lock_reason": resets.weekly_block_reason(usage),
|
|
117
118
|
"maintenance": options.get("maintenance", False),
|
|
118
119
|
"next_check": min(
|
|
119
120
|
(b["next_check"] for b in blocks if b["account"] == label),
|
package/python/resets.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
|
+
import math
|
|
5
6
|
import time
|
|
6
7
|
import uuid
|
|
7
8
|
|
|
@@ -9,6 +10,29 @@ from pool import Pool, blocked, reset_time
|
|
|
9
10
|
from settings import atomic_json
|
|
10
11
|
|
|
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
|
|
12
36
|
|
|
13
37
|
|
|
14
38
|
def available(snapshot):
|
|
@@ -122,13 +146,16 @@ async def consume(state, label, binary, request_id=None):
|
|
|
122
146
|
)
|
|
123
147
|
if known and outcome == "reset":
|
|
124
148
|
outcome = "alreadyRedeemed"
|
|
125
|
-
if not
|
|
149
|
+
if not known:
|
|
126
150
|
current = await rpc.call("account/rateLimits/read")
|
|
127
151
|
update_allowance(pool, label, current)
|
|
152
|
+
reason = weekly_block_reason(current)
|
|
153
|
+
if reason:
|
|
154
|
+
raise RouterError(reason)
|
|
128
155
|
count = available(current)
|
|
129
|
-
if count == 0:
|
|
156
|
+
if count == 0 and not retry:
|
|
130
157
|
return {"outcome": "noCredit", "refreshed": True}
|
|
131
|
-
if count is None:
|
|
158
|
+
if count is None and not retry:
|
|
132
159
|
raise RouterError(
|
|
133
160
|
"Codex did not report reset availability. Check allowance again before using a reset."
|
|
134
161
|
)
|
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.7.
|
|
27
|
+
VERSION = "0.7.2"
|
|
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"
|
package/tests/fake_native.py
CHANGED
|
@@ -127,10 +127,21 @@ for raw in sys.stdin:
|
|
|
127
127
|
result = {"type": params["type"]}
|
|
128
128
|
elif method == "account/rateLimits/read":
|
|
129
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
|
+
)
|
|
130
140
|
result = {
|
|
131
141
|
"ordinaryUsageAllowed": True,
|
|
132
142
|
"rateLimits": {
|
|
133
|
-
"primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600}
|
|
143
|
+
"primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600},
|
|
144
|
+
"secondary": weekly,
|
|
134
145
|
},
|
|
135
146
|
"rateLimitResetCredits": {
|
|
136
147
|
"availableCount": reset_count,
|
|
@@ -149,6 +160,8 @@ for raw in sys.stdin:
|
|
|
149
160
|
return {"outcome": "noCredit"}
|
|
150
161
|
keys.append(key)
|
|
151
162
|
data["reset_credit_count"] = count - 1
|
|
163
|
+
if "reset_weekly_used" in data:
|
|
164
|
+
data["reset_weekly_used"] = 0
|
|
152
165
|
return {"outcome": "reset"}
|
|
153
166
|
|
|
154
167
|
result = change(redeem)
|
package/tests/test_resets.py
CHANGED
|
@@ -27,6 +27,8 @@ class ResetService:
|
|
|
27
27
|
self.residual_limit = False
|
|
28
28
|
self.outcome = None
|
|
29
29
|
self.reject = False
|
|
30
|
+
self.weekly_used = 100
|
|
31
|
+
self.weekly_at = time.time() + 72 * 3600
|
|
30
32
|
|
|
31
33
|
async def call(self, method, params=None):
|
|
32
34
|
await asyncio.sleep(0)
|
|
@@ -39,7 +41,11 @@ class ResetService:
|
|
|
39
41
|
"primary": {
|
|
40
42
|
"usedPercent": 100 if self.blocked else 0,
|
|
41
43
|
"resetsAt": time.time() + 3600,
|
|
42
|
-
}
|
|
44
|
+
},
|
|
45
|
+
"secondary": {
|
|
46
|
+
"usedPercent": self.weekly_used,
|
|
47
|
+
"resetsAt": self.weekly_at,
|
|
48
|
+
},
|
|
43
49
|
},
|
|
44
50
|
"rateLimitResetCredits": {
|
|
45
51
|
"availableCount": self.count,
|
|
@@ -194,11 +200,67 @@ class ResetTests(unittest.IsolatedAsyncioTestCase):
|
|
|
194
200
|
with self.assertRaisesRegex(RouterError, "--yes"):
|
|
195
201
|
await commands.manage(args, self.root, "fake")
|
|
196
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
|
+
|
|
197
249
|
async def test_real_jsonl_helper_redeems_and_refreshes_synthetic_account(self):
|
|
198
250
|
from test_services import fake_executable
|
|
199
251
|
|
|
200
252
|
self.patch.stop()
|
|
201
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
|
+
)
|
|
202
264
|
result = await resets.consume(self.root, "one", binary)
|
|
203
265
|
self.assertEqual(result, {"outcome": "reset", "refreshed": True})
|
|
204
266
|
history = json.loads((self.home / "fake-history.json").read_text())
|