Skip to content

Commit 00e6dd4

Browse files
committed
feat(account): add usage limit reset control
1 parent 5f7ca1f commit 00e6dd4

26 files changed

Lines changed: 487 additions & 31 deletions

docs/app-server-events.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,14 @@ These are v2 request methods CodexMonitor currently sends to Codex app-server:
166166
- `account/login/start`
167167
- `account/login/cancel`
168168
- `account/rateLimits/read`
169+
- `account/rateLimitResetCredit/consume`
169170
- `account/read`
170171
- `skills/list`
171172
- `app/list`
172173

173174
Notes:
174175
- `turn/start` now forwards the optional `serviceTier` override (`"fast"` for `/fast`, `null` for default/off) alongside `model`, `effort`, and `collaborationMode`.
176+
- `account/rateLimits/read` may return snapshot-only `rateLimitResetCredits.availableCount`; `account/rateLimitResetCredit/consume` spends one earned reset with an idempotency key. Clients should refetch `account/rateLimits/read` after each consume attempt.
175177

176178
## Missing Client Requests (Codex v2 ClientRequest Methods)
177179

src-tauri/src/bin/codex_monitor_daemon.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,19 @@ impl DaemonState {
897897
codex_core::account_rate_limits_core(&self.sessions, workspace_id).await
898898
}
899899

900+
async fn consume_rate_limit_reset_credit(
901+
&self,
902+
workspace_id: String,
903+
idempotency_key: String,
904+
) -> Result<Value, String> {
905+
codex_core::consume_rate_limit_reset_credit_core(
906+
&self.sessions,
907+
workspace_id,
908+
idempotency_key,
909+
)
910+
.await
911+
}
912+
900913
async fn account_read(&self, workspace_id: String) -> Result<Value, String> {
901914
codex_core::account_read_core(&self.sessions, &self.workspaces, workspace_id).await
902915
}

src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,21 @@ pub(super) async fn try_handle(
392392
};
393393
Some(state.account_rate_limits(workspace_id).await)
394394
}
395+
"consume_rate_limit_reset_credit" => {
396+
let workspace_id = match parse_string(params, "workspaceId") {
397+
Ok(value) => value,
398+
Err(err) => return Some(Err(err)),
399+
};
400+
let idempotency_key = match parse_string(params, "idempotencyKey") {
401+
Ok(value) => value,
402+
Err(err) => return Some(Err(err)),
403+
};
404+
Some(
405+
state
406+
.consume_rate_limit_reset_credit(workspace_id, idempotency_key)
407+
.await,
408+
)
409+
}
395410
"account_read" => {
396411
let workspace_id = match parse_string(params, "workspaceId") {
397412
Ok(value) => value,

src-tauri/src/codex/mod.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,34 @@ pub(crate) async fn account_rate_limits(
731731
codex_core::account_rate_limits_core(&state.sessions, workspace_id).await
732732
}
733733

734+
#[tauri::command]
735+
pub(crate) async fn consume_rate_limit_reset_credit(
736+
workspace_id: String,
737+
idempotency_key: String,
738+
state: State<'_, AppState>,
739+
app: AppHandle,
740+
) -> Result<Value, String> {
741+
if remote_backend::is_remote_mode(&*state).await {
742+
return remote_backend::call_remote(
743+
&*state,
744+
app,
745+
"consume_rate_limit_reset_credit",
746+
json!({
747+
"workspaceId": workspace_id,
748+
"idempotencyKey": idempotency_key,
749+
}),
750+
)
751+
.await;
752+
}
753+
754+
codex_core::consume_rate_limit_reset_credit_core(
755+
&state.sessions,
756+
workspace_id,
757+
idempotency_key,
758+
)
759+
.await
760+
}
761+
734762
#[tauri::command]
735763
pub(crate) async fn account_read(
736764
workspace_id: String,

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ pub fn run() {
273273
codex::read_agent_config_toml,
274274
codex::write_agent_config_toml,
275275
codex::account_rate_limits,
276+
codex::consume_rate_limit_reset_credit,
276277
codex::account_read,
277278
codex::saved_auth_profiles_list,
278279
codex::saved_auth_profile_sync_current,

src-tauri/src/remote_backend/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ fn can_retry_after_disconnect(method: &str) -> bool {
146146
matches!(
147147
method,
148148
"account_rate_limits"
149+
| "consume_rate_limit_reset_credit"
149150
| "account_read"
150151
| "apps_list"
151152
| "collaboration_mode_list"

src-tauri/src/shared/codex_core.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,25 @@ pub(crate) async fn account_rate_limits_core(
629629
.await
630630
}
631631

632+
pub(crate) async fn consume_rate_limit_reset_credit_core(
633+
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
634+
workspace_id: String,
635+
idempotency_key: String,
636+
) -> Result<Value, String> {
637+
let idempotency_key = idempotency_key.trim();
638+
if idempotency_key.is_empty() {
639+
return Err("idempotencyKey is required".to_string());
640+
}
641+
let session = get_session_clone(sessions, &workspace_id).await?;
642+
session
643+
.send_request_for_workspace(
644+
&workspace_id,
645+
"account/rateLimitResetCredit/consume",
646+
json!({ "idempotencyKey": idempotency_key }),
647+
)
648+
.await
649+
}
650+
632651
pub(crate) async fn account_read_core(
633652
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
634653
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,

src/features/app/components/MainApp.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
import { useAppShellOrchestration } from "@app/orchestration/useLayoutOrchestration";
8181
import { normalizeCodexArgsInput } from "@/utils/codexArgsInput";
8282
import { subscribeTrayOpenThread } from "@services/events";
83+
import { consumeRateLimitResetCredit } from "@services/tauri";
8384

8485
const SettingsView = lazy(() =>
8586
import("@settings/components/SettingsView").then((module) => ({
@@ -270,6 +271,7 @@ export default function MainApp() {
270271
>(() => {});
271272

272273
const { errorToasts, dismissErrorToast } = useErrorToasts();
274+
const [resettingUsageLimit, setResettingUsageLimit] = useState(false);
273275
const queueGitStatusRefreshRef = useRef<() => void>(() => {});
274276
const handleThreadMessageActivity = useCallback(() => {
275277
queueGitStatusRefreshRef.current();
@@ -732,6 +734,78 @@ export default function MainApp() {
732734
refreshAccountRateLimits,
733735
alertError,
734736
});
737+
const handleResetUsageLimit = useCallback(async () => {
738+
const workspaceId = activeWorkspaceId;
739+
const availableCount = activeRateLimits?.rateLimitResetCredits?.availableCount ?? 0;
740+
if (!workspaceId || availableCount <= 0 || resettingUsageLimit) {
741+
return;
742+
}
743+
744+
const confirmed = window.confirm(
745+
"Use 1 reset credit to reset your Codex usage limit?",
746+
);
747+
if (!confirmed) {
748+
return;
749+
}
750+
751+
const idempotencyKey =
752+
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
753+
? crypto.randomUUID()
754+
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
755+
756+
setResettingUsageLimit(true);
757+
addDebugEntry({
758+
id: `${Date.now()}-client-rate-limit-reset`,
759+
timestamp: Date.now(),
760+
source: "client",
761+
label: "account/rateLimitResetCredit/consume",
762+
payload: { workspaceId, idempotencyKey },
763+
});
764+
765+
try {
766+
const response = await consumeRateLimitResetCredit(workspaceId, idempotencyKey);
767+
addDebugEntry({
768+
id: `${Date.now()}-server-rate-limit-reset`,
769+
timestamp: Date.now(),
770+
source: "server",
771+
label: "account/rateLimitResetCredit/consume response",
772+
payload: response,
773+
});
774+
const result =
775+
response?.result && typeof response.result === "object"
776+
? response.result
777+
: response;
778+
const outcome = typeof result?.outcome === "string" ? result.outcome : null;
779+
if (outcome === "noCredit") {
780+
alertError("No reset credits are available.");
781+
} else if (outcome === "nothingToReset") {
782+
alertError("There is no active usage limit to reset.");
783+
}
784+
await refreshAccountRateLimits(workspaceId);
785+
} catch (error) {
786+
addDebugEntry({
787+
id: `${Date.now()}-client-rate-limit-reset-error`,
788+
timestamp: Date.now(),
789+
source: "error",
790+
label: "account/rateLimitResetCredit/consume error",
791+
payload: error instanceof Error ? error.message : String(error),
792+
});
793+
alertError(
794+
error instanceof Error
795+
? `Reset failed: ${error.message}`
796+
: "Reset failed.",
797+
);
798+
} finally {
799+
setResettingUsageLimit(false);
800+
}
801+
}, [
802+
activeRateLimits?.rateLimitResetCredits?.availableCount,
803+
activeWorkspaceId,
804+
addDebugEntry,
805+
alertError,
806+
refreshAccountRateLimits,
807+
resettingUsageLimit,
808+
]);
735809
const {
736810
newAgentDraftWorkspaceId,
737811
startingDraftThreadWorkspaceId,
@@ -1632,6 +1706,8 @@ export default function MainApp() {
16321706
onSwitchAccount: handleSwitchAccount,
16331707
onCancelSwitchAccount: handleCancelSwitchAccount,
16341708
onActivateSavedProfile: handleActivateSavedProfile,
1709+
onResetUsageLimit: handleResetUsageLimit,
1710+
resettingUsageLimit,
16351711
onDecision: handleApprovalDecision,
16361712
onRemember: handleApprovalRemember,
16371713
onUserInputSubmit: handleUserInputSubmit,

src/features/app/components/Sidebar.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const baseProps = {
4141
onCancelSwitchAccount: vi.fn(),
4242
onActivateSavedProfile: vi.fn(),
4343
accountSwitching: false,
44+
onResetUsageLimit: vi.fn(),
45+
resettingUsageLimit: false,
4446
onOpenSettings: vi.fn(),
4547
onOpenDebug: vi.fn(),
4648
showDebugButton: false,
@@ -148,6 +150,7 @@ describe("Sidebar", () => {
148150
unlimited: false,
149151
balance: "120",
150152
},
153+
rateLimitResetCredits: null,
151154
planType: "pro",
152155
}}
153156
/>,
@@ -157,6 +160,38 @@ describe("Sidebar", () => {
157160
expect(creditsLabel.textContent ?? "").toContain("120");
158161
});
159162

163+
it("shows reset credits and invokes the reset action", () => {
164+
const onResetUsageLimit = vi.fn();
165+
render(
166+
<Sidebar
167+
{...baseProps}
168+
activeWorkspaceId="ws-1"
169+
onResetUsageLimit={onResetUsageLimit}
170+
accountRateLimits={{
171+
primary: {
172+
usedPercent: 62,
173+
windowDurationMins: 300,
174+
resetsAt: Math.round(Date.now() / 1000) + 3600,
175+
},
176+
secondary: null,
177+
credits: null,
178+
rateLimitResetCredits: {
179+
availableCount: 2,
180+
},
181+
planType: "pro",
182+
}}
183+
/>,
184+
);
185+
186+
expect(screen.getByText("2 resets")).toBeTruthy();
187+
const resetButton = screen.getByRole("button", { name: "Reset usage limit" });
188+
expect((resetButton as HTMLButtonElement).disabled).toBe(false);
189+
190+
fireEvent.click(resetButton);
191+
192+
expect(onResetUsageLimit).toHaveBeenCalledTimes(1);
193+
});
194+
160195
it("opens the account menu from the bottom rail", () => {
161196
render(
162197
<Sidebar

src/features/app/components/Sidebar.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ type SidebarProps = {
129129
onCancelSwitchAccount: () => void;
130130
onActivateSavedProfile: (profileId: string) => void;
131131
accountSwitching: boolean;
132+
onResetUsageLimit: () => void;
133+
resettingUsageLimit: boolean;
132134
onOpenSettings: () => void;
133135
onOpenDebug: () => void;
134136
showDebugButton: boolean;
@@ -194,6 +196,8 @@ export const Sidebar = memo(function Sidebar({
194196
onCancelSwitchAccount,
195197
onActivateSavedProfile,
196198
accountSwitching,
199+
onResetUsageLimit,
200+
resettingUsageLimit,
197201
onOpenSettings,
198202
onOpenDebug,
199203
showDebugButton,
@@ -268,6 +272,7 @@ export const Sidebar = memo(function Sidebar({
268272
sessionResetLabel,
269273
weeklyResetLabel,
270274
creditsLabel,
275+
resetCreditsLabel,
271276
showWeekly,
272277
} = getUsageLabels(accountRateLimits, usageShowRemaining);
273278
const debouncedQuery = useDebouncedValue(searchQuery, 150);
@@ -1042,7 +1047,15 @@ export const Sidebar = memo(function Sidebar({
10421047
sessionResetLabel={sessionResetLabel}
10431048
weeklyResetLabel={weeklyResetLabel}
10441049
creditsLabel={creditsLabel}
1050+
resetCreditsLabel={resetCreditsLabel}
10451051
showWeekly={showWeekly}
1052+
onResetUsageLimit={onResetUsageLimit}
1053+
resetUsageDisabled={
1054+
resettingUsageLimit ||
1055+
!activeWorkspaceId ||
1056+
!accountRateLimits?.rateLimitResetCredits?.availableCount
1057+
}
1058+
resettingUsageLimit={resettingUsageLimit}
10461059
onOpenSettings={onOpenSettings}
10471060
onOpenDebug={onOpenDebug}
10481061
showDebugButton={showDebugButton}

0 commit comments

Comments
 (0)