Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command
| [`codex-auth remove --all`](./docs/commands/remove.md) | Remove all stored accounts |
| [`codex-auth alias set <query> <alias>`](./docs/commands/alias.md) | Set an alias for an account |
| [`codex-auth alias clear <query>`](./docs/commands/alias.md) | Clear the alias for an account |
| [`codex-auth reset <query> --yes`](./docs/commands/reset.md) | Consume one rate-limit reset credit |

### Import and Maintenance

Expand All @@ -101,6 +102,7 @@ codex-auth list --active
codex-auth switch
codex-auth switch 02
codex-auth remove work
codex-auth reset work --yes
codex-auth import /path/to/auth.json --alias personal
codex-auth list --skip-api
```
Expand Down
1 change: 1 addition & 0 deletions docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This directory documents command behavior by command. Use `codex-auth <command>
| `switch` | [docs/commands/switch.md](./switch.md) |
| `remove` | [docs/commands/remove.md](./remove.md) |
| `alias` | [docs/commands/alias.md](./alias.md) |
| `reset` | [docs/commands/reset.md](./reset.md) |
| `clean` | [docs/commands/clean.md](./clean.md) |
| `config` | [docs/commands/config.md](./config.md) |
| `app` | [docs/commands/app.md](./app.md) |
Expand Down
3 changes: 2 additions & 1 deletion docs/commands/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ codex-auth list --skip-api
- Syncs the current `auth.json` into the registry before rendering when the current auth file is parseable.
- Shows selectable row numbers using the same ordering as `switch` and `remove`.
- Groups rows by email when the same email owns multiple account snapshots.
- Shows `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, and `LAST ACTIVITY`.
- Shows `ACCOUNT`, `PLAN`, `RESETS`, `5H`, `WEEKLY`, and `LAST ACTIVITY`.

## Refresh Modes

Expand All @@ -34,6 +34,7 @@ When local-only refresh is active, only the active account can be updated from l
- Singleton rows with both alias and account name render as `alias(account name, email)`.
- Grouped rows keep the shared email in the header; child rows with both alias and account name render as `alias(account name)`.
- Usage cells show remaining percent and reset time when that data is known.
- `RESETS` shows the stored reset-credit count when remote usage refresh provides it.
- Remote refresh failures can render row overlays such as `401`, `403`, `TimedOut`, or `MissingAuth`.
- `LAST ACTIVITY` is based on the last stored usage update time.
- Shared table layout policy is documented in [docs/table-layout.md](../table-layout.md).
34 changes: 34 additions & 0 deletions docs/commands/reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# `codex-auth reset`

## Usage

```shell
codex-auth reset <query> --yes
```

## Behavior

- Consumes one rate-limit reset credit for a stored ChatGPT account.
- Requires `--yes`; without it, the command refuses to consume a credit.
- Uses stored account auth from `CODEX_HOME/accounts`.
- Updates the stored reset-credit count when the account already has one.

## Selector Rules

`<query>` resolves from stored local data only. It does not trigger API refresh.

Selectors can match:

- displayed row number,
- alias fragment,
- email fragment, or
- account name fragment.

If multiple accounts match, use a displayed row number from `codex-auth list`.

## Examples

```shell
codex-auth reset 02 --yes
codex-auth reset work --yes
```
1 change: 1 addition & 0 deletions src/api/http.zig
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub const BatchHttpResult = types.BatchHttpResult;
pub const ChildCaptureResult = types.ChildCaptureResult;

pub const runGetJsonCommand = curl.runGetJsonCommand;
pub const runPostJsonCommand = curl.runPostJsonCommand;
pub const runBearerGetJsonCommand = curl.runBearerGetJsonCommand;
pub const runGetJsonBatchCommand = curl.runGetJsonBatchCommand;
pub const ensureCurlExecutableAvailable = curl.ensureCurlExecutableAvailable;
Expand Down
48 changes: 48 additions & 0 deletions src/api/http_curl.zig
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ pub fn runGetJsonCommand(
return runCurlGetJsonCommand(allocator, endpoint, access_token, account_id);
}

pub fn runPostJsonCommand(
allocator: std.mem.Allocator,
endpoint: []const u8,
access_token: []const u8,
account_id: []const u8,
body: []const u8,
) !HttpResult {
return runCurlPostJsonCommand(allocator, endpoint, access_token, account_id, body);
}

pub fn runBearerGetJsonCommand(
allocator: std.mem.Allocator,
endpoint: []const u8,
Expand Down Expand Up @@ -134,6 +144,30 @@ fn runCurlGetJsonCommand(
return runCurlGetJsonCommandWithExecutable(allocator, curl_executable, endpoint, access_token, account_id);
}

fn runCurlPostJsonCommand(
allocator: std.mem.Allocator,
endpoint: []const u8,
access_token: []const u8,
account_id: []const u8,
body: []const u8,
) !HttpResult {
const curl_executable = try resolveCurlExecutableForLaunchAlloc(allocator);
defer allocator.free(curl_executable);

const authorization = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{access_token});
defer allocator.free(authorization);
const account_header = try std.fmt.allocPrint(allocator, "ChatGPT-Account-Id: {s}", .{account_id});
defer allocator.free(account_header);

return try runCurlJsonCommandWithExecutableAndBody(
allocator,
curl_executable,
endpoint,
&[_][]const u8{ authorization, account_header, "Content-Type: application/json" },
body,
);
}

fn runCurlGetJsonCommandWithExecutable(
allocator: std.mem.Allocator,
curl_executable: []const u8,
Expand Down Expand Up @@ -165,6 +199,16 @@ fn runCurlJsonCommandWithExecutable(
curl_executable: []const u8,
endpoint: []const u8,
headers: []const []const u8,
) !HttpResult {
return runCurlJsonCommandWithExecutableAndBody(allocator, curl_executable, endpoint, headers, null);
}

fn runCurlJsonCommandWithExecutableAndBody(
allocator: std.mem.Allocator,
curl_executable: []const u8,
endpoint: []const u8,
headers: []const []const u8,
body: ?[]const u8,
) !HttpResult {
const user_agent_header = "User-Agent: " ++ user_agent;

Expand All @@ -180,6 +224,10 @@ fn runCurlJsonCommandWithExecutable(
for (headers) |header| {
try appendCurlConfigLine(allocator, &curl_config, "header", header);
}
if (body) |value| {
try appendCurlConfigLine(allocator, &curl_config, "request", "POST");
try appendCurlConfigLine(allocator, &curl_config, "data", value);
}

const result = runChildCaptureWithInputAndOutputLimit(
allocator,
Expand Down
109 changes: 108 additions & 1 deletion src/api/usage.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const chatgpt_http = @import("http.zig");
const registry = @import("../registry/root.zig");

pub const default_usage_endpoint = "https://chatgpt.com/backend-api/wham/usage";
pub const default_reset_consume_endpoint = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";

pub const UsageFetchResult = struct {
snapshot: ?registry.RateLimitSnapshot,
Expand All @@ -12,6 +13,18 @@ pub const UsageFetchResult = struct {
missing_auth: bool = false,
};

pub const ResetConsumeResult = struct {
code: ?[]u8,
windows_reset: ?bool,
redeem_request_id: []u8,

pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void {
if (self.code) |value| allocator.free(value);
allocator.free(self.redeem_request_id);
self.* = undefined;
}
};

pub const max_response_error_code_bytes: usize = 64;

pub const ResponseErrorCode = struct {
Expand Down Expand Up @@ -207,6 +220,70 @@ pub fn fetchUsageForTokenDetailed(
};
}

pub fn consumeResetForAuthPath(
allocator: std.mem.Allocator,
auth_path: []const u8,
) !ResetConsumeResult {
const info = try auth.parseAuthInfo(allocator, auth_path);
defer info.deinit(allocator);

if (info.auth_mode == .apikey) return error.UnsupportedAuthMode;
if (info.auth_mode != .chatgpt) return error.MissingAuth;
const access_token = info.access_token orelse return error.MissingAuth;
const chatgpt_account_id = info.chatgpt_account_id orelse return error.MissingAuth;

return try consumeResetForToken(allocator, default_reset_consume_endpoint, access_token, chatgpt_account_id);
}

pub fn consumeResetForToken(
allocator: std.mem.Allocator,
endpoint: []const u8,
access_token: []const u8,
account_id: []const u8,
) !ResetConsumeResult {
const request_id = try randomRedeemRequestIdAlloc(allocator);
errdefer allocator.free(request_id);

const body = try std.fmt.allocPrint(allocator, "{{\"redeem_request_id\":\"{s}\"}}", .{request_id});
defer allocator.free(body);

const http_result = try chatgpt_http.runPostJsonCommand(allocator, endpoint, access_token, account_id, body);
defer allocator.free(http_result.body);
if (http_result.body.len == 0 or isNonSuccessStatus(http_result.status_code)) return error.RequestFailed;

var result = try parseResetConsumeResponse(allocator, http_result.body);
allocator.free(result.redeem_request_id);
result.redeem_request_id = request_id;
return result;
}

pub fn parseResetConsumeResponse(allocator: std.mem.Allocator, body: []const u8) !ResetConsumeResult {
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
defer parsed.deinit();

const root_obj = switch (parsed.value) {
.object => |obj| obj,
else => return error.InvalidResponse,
};

const code = if (root_obj.get("code")) |value| switch (value) {
.string => |s| if (s.len == 0) null else try allocator.dupe(u8, s),
else => null,
} else null;
errdefer if (code) |value| allocator.free(value);

const windows_reset = if (root_obj.get("windows_reset")) |value| switch (value) {
.bool => |b| b,
else => null,
} else null;

return .{
.code = code,
.windows_reset = windows_reset,
.redeem_request_id = try allocator.dupe(u8, ""),
};
}

fn isNonSuccessStatus(status_code: ?u16) bool {
const status = status_code orelse return false;
return status < 200 or status > 299;
Expand Down Expand Up @@ -261,6 +338,7 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi
.primary = null,
.secondary = null,
.credits = null,
.reset_credits = null,
.plan_type = null,
};

Expand All @@ -270,6 +348,9 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi
if (root_obj.get("credits")) |credits| {
snapshot.credits = try parseCredits(allocator, credits);
}
if (root_obj.get("rate_limit_reset_credits")) |reset_credits| {
snapshot.reset_credits = parseResetCredits(reset_credits);
}
if (root_obj.get("rate_limit")) |rate_limit| {
switch (rate_limit) {
.object => |obj| {
Expand All @@ -284,7 +365,7 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi
}
}

if (snapshot.primary == null and snapshot.secondary == null) {
if (snapshot.primary == null and snapshot.secondary == null and snapshot.reset_credits == null) {
if (snapshot.credits) |*credits| {
if (credits.balance) |balance| allocator.free(balance);
}
Expand All @@ -294,6 +375,17 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi
return snapshot;
}

fn parseResetCredits(v: std.json.Value) ?i64 {
const obj = switch (v) {
.object => |o| o,
else => return null,
};
return switch (obj.get("available_count") orelse return null) {
.integer => |i| i,
else => null,
};
}

fn parseWindow(v: std.json.Value) ?registry.RateLimitWindow {
const obj = switch (v) {
.object => |o| o,
Expand Down Expand Up @@ -370,6 +462,21 @@ fn ceilMinutes(seconds: i64) ?i64 {
return @divTrunc(seconds + 59, 60);
}

fn randomRedeemRequestIdAlloc(allocator: std.mem.Allocator) ![]u8 {
var bytes: [16]u8 = undefined;
@import("../core/runtime.zig").io().random(&bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = std.fmt.bytesToHex(bytes, .lower);
return std.fmt.allocPrint(allocator, "{s}-{s}-{s}-{s}-{s}", .{
hex[0..8],
hex[8..12],
hex[12..16],
hex[16..20],
hex[20..32],
});
}

fn runUsageCommand(
allocator: std.mem.Allocator,
endpoint: []const u8,
Expand Down
32 changes: 32 additions & 0 deletions src/cli/commands/reset.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const std = @import("std");
const types = @import("../types.zig");
const common = @import("common.zig");

pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult {
if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) {
return .{ .command = .{ .help = .reset } };
}

var selector: ?[]u8 = null;
errdefer if (selector) |value| allocator.free(value);
var opts: types.ResetOptions = .{
.selector = &.{},
.yes = false,
};

for (args) |raw_arg| {
const arg = std.mem.sliceTo(raw_arg, 0);
if (std.mem.eql(u8, arg, "--yes")) {
if (opts.yes) return common.usageErrorResult(allocator, .reset, "duplicate `--yes` for `reset`.", .{});
opts.yes = true;
continue;
}
if (std.mem.startsWith(u8, arg, "-")) return common.usageErrorResult(allocator, .reset, "unknown flag `{s}` for `reset`.", .{arg});
if (selector != null) return common.usageErrorResult(allocator, .reset, "unexpected extra argument `{s}` for `reset`.", .{arg});
selector = try allocator.dupe(u8, arg);
}

opts.selector = selector orelse return common.usageErrorResult(allocator, .reset, "`reset` requires an account selector.", .{});
selector = null;
return .{ .command = .{ .reset = opts } };
}
4 changes: 4 additions & 0 deletions src/cli/commands/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const import_auth = @import("import.zig");
const list = @import("list.zig");
const login = @import("login.zig");
const remove = @import("remove.zig");
const reset = @import("reset.zig");
const switch_account = @import("switch.zig");

pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult {
Expand Down Expand Up @@ -55,6 +56,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type
if (std.mem.eql(u8, cmd, "switch")) return switch_account.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "remove")) return remove.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "alias")) return alias.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "reset")) return reset.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "clean")) return clean.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "config")) return config.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "app")) return app.parse(allocator, args[2..]);
Expand Down Expand Up @@ -91,6 +93,7 @@ fn freeCommand(allocator: std.mem.Allocator, cmd: *types.Command) void {
},
.clear => |clear_opts| allocator.free(clear_opts.selector),
},
.reset => |opts| allocator.free(opts.selector),
else => {},
}
cmd.* = undefined;
Expand All @@ -117,6 +120,7 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic {
if (std.mem.eql(u8, name, "switch")) return .switch_account;
if (std.mem.eql(u8, name, "remove")) return .remove_account;
if (std.mem.eql(u8, name, "alias")) return .alias;
if (std.mem.eql(u8, name, "reset")) return .reset;
if (std.mem.eql(u8, name, "clean")) return .clean;
if (std.mem.eql(u8, name, "config")) return .config;
if (std.mem.eql(u8, name, "app")) return .app;
Expand Down
Loading