Skip to content
Merged
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
11 changes: 11 additions & 0 deletions docs/commands/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ codex-auth list --json
- `--api` is accepted as an explicit equivalent to default mode.
- `--skip-api` forbids remote API calls for this command.
- `--live` keeps refreshing the terminal view and requires a TTY.
- In live tables, `*` before the number marks the active account and `!` marks a refresh error.
- Live account details show the access-token expiry and, per account, the
available reset-credit count with the earliest card expiry. The
`ACCESS EXPIRE` and `RESET CREDITS` columns render in local time; the
available reset credits are also expandable per account in the live view.
- Access-token expiry is shown in local time and is not the account or subscription
expiry; refreshing credentials can extend it. Plan expiry is shown as unavailable
because the current data sources do not provide it. Server card lists may be
capped; compare `available_count` with the JSON `credits` length before treating
the list as exhaustive. After a refresh failure, details may be cached.
- `--json` emits one machine-readable JSON document and cannot be combined with `--live`.

When local-only refresh is active, only the active account can be updated from local rollout files. Non-active rows use the stored registry snapshot.
Expand All @@ -38,6 +48,7 @@ When local-only refresh is active, only the active account can be updated from l
- Usage cells show remaining percent and reset time when that data is known.
- In non-live output, `CREDITS` shows the integer part of the current `credits.balance` value.
- `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it.
- JSON output includes the returned reset-credit details.
- 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.
- `--json` returns accounts in the same display order and includes the same row numbers shown by the table.
Expand Down
16 changes: 16 additions & 0 deletions docs/json-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ shortcut and is rejected when combined with `--json`.
"account_name": null,
"plan": "business",
"auth_mode": "chatgpt",
"access_token_expires_at": null,
"active": true,
"created_at": 1730000000,
"last_used_at": 1730001000,
Expand All @@ -64,6 +65,7 @@ shortcut and is rejected when combined with `--json`.
"balance": null
},
"reset_credits": null,
"reset_credit": null,
"refresh": {
"requested": true,
"method": "api",
Expand All @@ -75,10 +77,24 @@ shortcut and is rejected when combined with `--json`.
}
```

`reset_credits` remains the backward-compatible available-count field.
`reset_credit`, when present, lists the available reset-credit cards with
their `id`, `status`, `expires_at`, and `title`. The backend may cap the
returned list, so treat it as non-exhaustive. All expiry timestamps in the
terminal views render in local time.

The API currently does not expose a verifiable subscription-plan expiration
timestamp, so the JSON contract intentionally does not label token expiry as
plan expiry.

`account_key` is stable and should be used for switch/remove calls. `number` is
an ephemeral display selector valid only for the ordering returned by the
current invocation. Empty aliases and account names are `null`.

`access_token_expires_at` is the Unix timestamp when the stored access token expires.
It describes credential lifetime only; it is not a subscription or account
entitlement expiration timestamp.

`plan` is already normalized by the CLI. Important mappings are:

| Input observed by the CLI | JSON plan |
Expand Down
70 changes: 68 additions & 2 deletions src/api/usage.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ const std = @import("std");
const auth = @import("../auth/auth.zig");
const chatgpt_http = @import("http.zig");
const registry = @import("../registry/root.zig");
const registry_parse = @import("../registry/parse.zig");

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

pub const UsageFetchResult = struct {
snapshot: ?registry.RateLimitSnapshot,
status_code: ?u16,
error_code: ?ResponseErrorCode = null,
missing_auth: bool = false,
access_token_expires_at: ?i64 = null,
};

pub const max_response_error_code_bytes: usize = 64;
Expand All @@ -29,6 +32,7 @@ pub const BatchUsageFetchResult = struct {
error_code: ?ResponseErrorCode = null,
missing_auth: bool = false,
error_name: ?[]const u8 = null,
access_token_expires_at: ?i64 = null,

pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void {
if (self.snapshot) |*snapshot| {
Expand Down Expand Up @@ -74,7 +78,9 @@ pub fn fetchUsageForAuthPathDetailed(allocator: std.mem.Allocator, auth_path: []
const access_token = info.access_token orelse return .{ .snapshot = null, .status_code = null, .missing_auth = true };
const chatgpt_account_id = info.chatgpt_account_id orelse return .{ .snapshot = null, .status_code = null, .missing_auth = true };

return try fetchUsageForTokenDetailed(allocator, default_usage_endpoint, access_token, chatgpt_account_id);
var result = try fetchUsageForTokenDetailed(allocator, default_usage_endpoint, access_token, chatgpt_account_id);
result.access_token_expires_at = auth.accessTokenExpiresAt(allocator, access_token) catch null;
return result;
}

pub fn fetchUsageForAuthPathsDetailedBatch(
Expand Down Expand Up @@ -104,6 +110,7 @@ pub fn fetchUsageForAuthPathsDetailedBatch(
continue;
};
defer info.deinit(arena);
results[idx].access_token_expires_at = info.access_token_expires_at;

if (info.auth_mode == .apikey) {
continue;
Expand Down Expand Up @@ -171,6 +178,32 @@ pub fn fetchUsageForAuthPathsDetailedBatch(
}
}

var has_snapshot = false;
for (results) |result| {
if (result.snapshot != null) {
has_snapshot = true;
break;
}
}
if (!has_snapshot) return results;

var reset_credit_http_results = chatgpt_http.runGetJsonBatchCommand(
allocator,
default_reset_credits_endpoint,
requests.items,
max_concurrency,
) catch return results;
defer reset_credit_http_results.deinit(allocator);

for (request_indexes, 0..) |request_idx, result_idx| {
const unique_idx = request_idx orelse continue;
if (results[result_idx].snapshot == null) continue;
const http_result = reset_credit_http_results.items[unique_idx];
if (http_result.outcome != .ok or http_result.body.len == 0 or isNonSuccessStatus(http_result.status_code)) continue;
const details = parseResetCreditResponse(allocator, http_result.body) catch null;
if (details) |value| results[result_idx].snapshot.?.reset_credit = value;
}

return results;
}

Expand Down Expand Up @@ -200,13 +233,31 @@ pub fn fetchUsageForTokenDetailed(
return .{ .snapshot = null, .status_code = http_result.status_code, .error_code = error_code };
}

var snapshot = try parseUsageResponse(allocator, http_result.body);
if (snapshot) |*value| {
errdefer registry.freeRateLimitSnapshot(allocator, value);
if (fetchResetCreditDetailsForToken(allocator, access_token, account_id) catch null) |details| {
value.reset_credit = details;
}
}
return .{
.snapshot = try parseUsageResponse(allocator, http_result.body),
.snapshot = snapshot,
.status_code = http_result.status_code,
.error_code = error_code,
};
}

fn fetchResetCreditDetailsForToken(
allocator: std.mem.Allocator,
access_token: []const u8,
account_id: []const u8,
) !?[]registry.ResetCreditCard {
const http_result = try chatgpt_http.runGetJsonCommand(allocator, default_reset_credits_endpoint, access_token, account_id);
defer allocator.free(http_result.body);
if (http_result.body.len == 0 or isNonSuccessStatus(http_result.status_code)) return null;
return try parseResetCreditResponse(allocator, http_result.body);
}

fn isNonSuccessStatus(status_code: ?u16) bool {
const status = status_code orelse return false;
return status < 200 or status > 299;
Expand Down Expand Up @@ -299,6 +350,21 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi
return snapshot;
}

pub fn parseResetCreditResponse(allocator: std.mem.Allocator, body: []const u8) !?[]registry.ResetCreditCard {
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{ .parse_numbers = false });
defer parsed.deinit();
const obj = switch (parsed.value) {
.object => |o| o,
else => return null,
};
const credits = obj.get("credits") orelse return null;
const items = switch (credits) {
.array => |array| array.items,
else => return null,
};
return try registry_parse.parseResetCreditCards(allocator, items);
}

fn parseResetCredits(v: std.json.Value) ?i64 {
const obj = switch (v) {
.object => |o| o,
Expand Down
29 changes: 29 additions & 0 deletions src/auth/auth.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub const AuthInfo = struct {
access_token: ?[]u8,
openai_api_key: ?[]u8 = null,
last_refresh: ?[]u8,
access_token_expires_at: ?i64 = null,
plan: ?registry.PlanType,
auth_mode: registry.AuthMode,

Expand Down Expand Up @@ -90,6 +91,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn
.access_token = null,
.openai_api_key = try allocator.dupe(u8, trimmed),
.last_refresh = null,
.access_token_expires_at = null,
.plan = null,
.auth_mode = .apikey,
};
Expand Down Expand Up @@ -193,6 +195,10 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn
const chatgpt_account_id = try resolveChatGptAccountId(token_chatgpt_account_id, jwt_chatgpt_account_id);
const chatgpt_user_id_value = chatgpt_user_id orelse return error.MissingChatgptUserId;
const record_key = try recordKeyAlloc(allocator, chatgpt_user_id_value, chatgpt_account_id);
const access_token_expires_at = if (access_token) |token|
jwtExpiryClaim(allocator, token) catch null
else
null;

const info = AuthInfo{
.email = email,
Expand All @@ -202,6 +208,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn
.access_token = access_token,
.openai_api_key = null,
.last_refresh = last_refresh,
.access_token_expires_at = access_token_expires_at,
.plan = plan,
.auth_mode = .chatgpt,
};
Expand Down Expand Up @@ -238,6 +245,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn
.access_token = null,
.openai_api_key = null,
.last_refresh = null,
.access_token_expires_at = null,
.plan = null,
.auth_mode = .chatgpt,
};
Expand Down Expand Up @@ -317,6 +325,27 @@ pub fn decodeJwtPayload(allocator: std.mem.Allocator, jwt: []const u8) ![]u8 {
return decoded;
}

fn jwtExpiryClaim(allocator: std.mem.Allocator, jwt: []const u8) !?i64 {
const payload = try decodeJwtPayload(allocator, jwt);
defer allocator.free(payload);
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{});
defer parsed.deinit();
const obj = switch (parsed.value) {
.object => |value| value,
else => return null,
};
const value = obj.get("exp") orelse return null;
return switch (value) {
.integer => |number| number,
.number_string, .string => |text| std.fmt.parseInt(i64, text, 10) catch null,
else => null,
};
}

pub fn accessTokenExpiresAt(allocator: std.mem.Allocator, jwt: []const u8) !?i64 {
return try jwtExpiryClaim(allocator, jwt);
}

fn base64UrlNoPadDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
const decoder = std.base64.url_safe_no_pad.Decoder;
const out_len = decoder.calcSizeForSlice(input) catch return error.InvalidBase64;
Expand Down
28 changes: 28 additions & 0 deletions src/cli/json_output.zig
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ fn writeAccount(jw: *std.json.Stringify, account: *const results.AccountView) !v
try writeOptionalText(jw, planName(account.plan));
try jw.objectField("auth_mode");
try writeOptionalText(jw, authModeName(account.auth_mode));
try jw.objectField("access_token_expires_at");
try jw.write(account.access_token_expires_at);
try jw.objectField("active");
try jw.write(account.active);
try jw.objectField("created_at");
Expand All @@ -191,11 +193,37 @@ fn writeUsage(jw: *std.json.Stringify, usage: *const results.UsageView) !void {
try writeCredits(jw, usage.credits);
try jw.objectField("reset_credits");
try jw.write(usage.reset_credits);
try jw.objectField("reset_credit");
try writeResetCreditList(jw, usage.reset_credit);
try jw.objectField("refresh");
try writeUsageRefresh(jw, &usage.refresh);
try jw.endObject();
}

fn writeResetCreditList(
jw: *std.json.Stringify,
details: ?[]registry.ResetCreditCard,
) !void {
const value = details orelse {
try jw.write(null);
return;
};
try jw.beginArray();
for (value) |card| {
try jw.beginObject();
try jw.objectField("id");
try jw.write(card.id);
try jw.objectField("status");
try jw.write(card.status);
try jw.objectField("expires_at");
try jw.write(card.expires_at);
try jw.objectField("title");
try jw.write(card.title);
try jw.endObject();
}
try jw.endArray();
}

fn usageSourceName(source: results.UsageSource) []const u8 {
return switch (source) {
.api => "api",
Expand Down
Loading