Skip to content
Open
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,12 @@ This project is provided as-is and use is at your own risk.
**Usage Data Refresh Source:**
`codex-auth` supports two sources for refreshing account usage/usage limit information:

1. **API (default):** The tool makes direct HTTPS requests to OpenAI's endpoints using your account's access token. This enables both usage refresh and team name refresh. npm installs already satisfy the runtime requirement.
1. **API (default):** The tool makes direct HTTPS requests to OpenAI's endpoints using your account's access token. This enables both usage refresh and team name refresh. `curl` must be available at runtime.
2. **Local-only:** With per-command `--skip-api`, the tool scans local `~/.codex/sessions/*/rollout-*.jsonl` files for usage data and skips team name refresh API calls. This mode is safer, but it can be less accurate because recent Codex rollout files often contain `rate_limits: null`, so the latest local usage limit data may lag by several hours.

**API Call Declaration:**
By using the default API-backed refresh, this tool will send your ChatGPT access token to OpenAI's servers, including `https://chatgpt.com/backend-api/wham/usage` for usage limit and `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` for team name. This behavior may be detected by OpenAI and could violate their terms of service, potentially leading to account suspension or other risks. The decision to use this feature and any resulting consequences are entirely yours.
By using the default API-backed refresh, this tool will send your ChatGPT access token to OpenAI's servers for usage limit and team name refresh. The exact endpoints are:
- `GET https://chatgpt.com/backend-api/wham/usage`
- `GET https://chatgpt.com/backend-api/accounts`

This behavior may be detected by OpenAI and could violate their terms of service, potentially leading to account suspension or other risks. The decision to use this feature and any resulting consequences are entirely yours.
26 changes: 20 additions & 6 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,30 @@ pub fn build(b: *std.Build) void {
});
b.installArtifact(exe);

const fake_node_module = b.createModule(.{
.root_source_file = b.path("tests/fake_node.zig"),
const fake_curl_module = b.createModule(.{
.root_source_file = b.path("tests/fake_curl.zig"),
.target = target,
.optimize = optimize,
});
const fake_node_exe = b.addExecutable(.{
.name = "fake-node",
.root_module = fake_node_module,
const fake_curl_exe = b.addExecutable(.{
.name = "curl",
.root_module = fake_curl_module,
});
b.installArtifact(fake_node_exe);
const install_fake_curl = b.addInstallArtifact(fake_curl_exe, .{});
const fake_curl_fail_module = b.createModule(.{
.root_source_file = b.path("tests/fake_curl_fail.zig"),
.target = target,
.optimize = optimize,
});
const fake_curl_fail_exe = b.addExecutable(.{
.name = "curl-fail",
.root_module = fake_curl_fail_module,
});
const install_fake_curl_fail = b.addInstallArtifact(fake_curl_fail_exe, .{});
const test_helpers_step = b.step("test-helpers", "Install test helper binaries");
test_helpers_step.dependOn(b.getInstallStep());
test_helpers_step.dependOn(&install_fake_curl.step);
test_helpers_step.dependOn(&install_fake_curl_fail.step);

const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
Expand Down
30 changes: 11 additions & 19 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,10 @@

This document is the single source of truth for outbound ChatGPT API refresh behavior in `codex-auth`.

All API refresh requests are issued through `Node.js fetch`.
When `codex-auth` is launched from the npm package, the wrapper passes its current Node executable to the Zig binary.
Legacy standalone binary installs must have Node.js 22+ available on `PATH` for API-backed refresh to work.
Built-in Node environment-proxy support for `fetch()` requires Node.js `22.21.0+` or `24.0.0+`.
All API refresh requests are issued through `curl`.
`codex-auth` resolves `curl` from `PATH`.

`codex-auth` configures proxy support for the fetch child process in this order:

1. inherit explicit `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` values from the parent process
2. map `ALL_PROXY` into `HTTP_PROXY` and `HTTPS_PROXY` when the direct variables are absent
3. on Windows only, when no proxy environment variables are present and the detected Node runtime supports env-proxy for `fetch()` (`22.21.0+` or `24.0.0+`), read `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings` and map HTTP/HTTPS/SOCKS `ProxyServer` entries into `HTTP_PROXY` / `HTTPS_PROXY`
4. on Windows only, map explicit `ProxyOverride` entries into `NO_PROXY`; the WinINet-only `<local>` shorthand is not translated
5. when proxy variables are configured and the detected Node runtime supports env-proxy for `fetch()`, set `NODE_USE_ENV_PROXY=1` for the Node child process automatically
`codex-auth` does not translate platform proxy settings. The curl child process inherits the parent process environment, and curl applies its own proxy environment variable handling.

## Endpoints

Expand All @@ -29,19 +21,19 @@ Built-in Node environment-proxy support for `fetch()` requires Node.js `22.21.0+
### Account Metadata Refresh

- method: `GET`
- URL: `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27`
- URL: `https://chatgpt.com/backend-api/accounts`
- headers:
- `Authorization: Bearer <tokens.access_token>`
- `ChatGPT-Account-Id: <chatgpt_account_id>`
- `User-Agent: codex-auth/<version>`

The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` and `name: ""` are both normalized to `account_name = null`.
The account metadata response is parsed from `items[].id` and `items[].name`. `name: null` and `name: ""` are both normalized to `account_name = null`. An empty `items` array, or an `items` array with no usable `id`, is treated as unusable and leaves stored `account_name` values unchanged.

## Usage Refresh Rules

- foreground refresh uses the usage API by default.
- `--skip-api` reads only the newest local `~/.codex/sessions/**/rollout-*.jsonl`.
- by default, `list` and interactive `switch` refresh all stored accounts before rendering, using stored auth snapshots under `accounts/` with a maximum concurrency of `3`
- by default, `list` and interactive `switch` refresh all stored accounts before rendering, using stored auth snapshots under `accounts/`
- when one of those per-account foreground usage requests returns a non-`200` HTTP status, the corresponding `list` / `switch` row shows that response status in both usage columns until a later successful refresh replaces it
- when a stored account snapshot cannot make a ChatGPT usage request because it is missing the required ChatGPT auth fields, the corresponding `list` / `switch` row shows `MissingAuth` in both usage columns until a later successful refresh replaces it
- with `--skip-api`, foreground refresh still uses only the active local rollout data because local session files do not identify the other stored accounts
Expand Down Expand Up @@ -71,7 +63,7 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an
- `list` and interactive `switch` load the request auth context from the current active `auth.json` when they do refresh.
- stored snapshots without a usable `access_token` or `chatgpt_account_id` are skipped.

At most one `accounts/check` request is attempted per grouped user scope in a given refresh pass.
At most one account metadata request is attempted per grouped user scope in a given refresh pass.
Request failures and unparseable responses are non-fatal and leave stored `account_name` values unchanged.

## Refresh Scope
Expand All @@ -89,15 +81,15 @@ That scope includes:

This means a `free`, `plus`, or `pro` record can still trigger a grouped Team-name refresh when it belongs to the same `chatgpt_user_id` as Team records.

`accounts/check` is attempted only when:
Account metadata refresh is attempted only when:

- the scope contains more than one record
- the scope contains at least one Team record
- at least one Team record in that scope still has `account_name = null`

## Apply Rules

After a successful `accounts/check` response:
After a successful account metadata response:

- returned entries are matched by `chatgpt_account_id`
- matched records overwrite the stored `account_name`, even when a Team record already had an older value
Expand All @@ -111,7 +103,7 @@ Example 1:
- active record: `user@example.com / Team #1 / account_name = null`
- same grouped scope: `user@example.com / Team #2 / account_name = null`

Running `codex-auth list` should issue `accounts/check`. If the API returns:
Running `codex-auth list` should issue an account metadata request. If the API returns:

- `team-1 -> "Workspace Alpha"`
- `team-2 -> "Workspace Beta"`
Expand All @@ -124,7 +116,7 @@ Example 2:
- same grouped scope: `user@example.com / Team #1 / account_name = null`
- same grouped scope: `user@example.com / Team #2 / account_name = "Old Workspace"`

Running `codex-auth list` should still issue `accounts/check`, because the grouped scope still has missing Team names. If the API returns:
Running `codex-auth list` should still issue an account metadata request, because the grouped scope still has missing Team names. If the API returns:

- `team-1 -> "Prod Workspace"`
- `team-2 -> "Sandbox Workspace"`
Expand Down
25 changes: 10 additions & 15 deletions src/api/account.zig
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const std = @import("std");
const chatgpt_http = @import("http.zig");

pub const default_account_endpoint = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27";
pub const default_account_endpoint = "https://chatgpt.com/backend-api/accounts";

pub const AccountEntry = struct {
account_id: []u8,
Expand Down Expand Up @@ -57,31 +57,25 @@ pub fn parseAccountsResponse(allocator: std.mem.Allocator, body: []const u8) !?[
.object => |obj| obj,
else => return null,
};
const accounts_value = root_obj.get("accounts") orelse return null;
const accounts_obj = switch (accounts_value) {
.object => |obj| obj,
const items_value = root_obj.get("items") orelse return null;
const items = switch (items_value) {
.array => |array| array.items,
else => return null,
};
if (items.len == 0) return null;

var entries = std.ArrayList(AccountEntry).empty;
errdefer {
for (entries.items) |*entry| entry.deinit(allocator);
entries.deinit(allocator);
}

var it = accounts_obj.iterator();
while (it.next()) |kv| {
if (std.mem.eql(u8, kv.key_ptr.*, "default")) continue;
const entry_obj = switch (kv.value_ptr.*) {
.object => |obj| obj,
else => continue,
};
const account_value = entry_obj.get("account") orelse continue;
const account_obj = switch (account_value) {
for (items) |item| {
const entry_obj = switch (item) {
.object => |obj| obj,
else => continue,
};
const account_id_value = account_obj.get("account_id") orelse continue;
const account_id_value = entry_obj.get("id") orelse continue;
const account_id = switch (account_id_value) {
.string => |value| value,
else => continue,
Expand All @@ -90,7 +84,7 @@ pub fn parseAccountsResponse(allocator: std.mem.Allocator, body: []const u8) !?[

const owned_account_id = try allocator.dupe(u8, account_id);
errdefer allocator.free(owned_account_id);
const owned_account_name = try parseAccountNameAlloc(allocator, account_obj.get("name"));
const owned_account_name = try parseAccountNameAlloc(allocator, entry_obj.get("name"));
errdefer if (owned_account_name) |name| allocator.free(name);

try entries.append(allocator, .{
Expand All @@ -99,6 +93,7 @@ pub fn parseAccountsResponse(allocator: std.mem.Allocator, body: []const u8) !?[
});
}

if (entries.items.len == 0) return null;
return try entries.toOwnedSlice(allocator);
}

Expand Down
32 changes: 8 additions & 24 deletions src/api/http.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,36 @@ pub const types = @import("http_types.zig");
pub const env = @import("http_env.zig");
pub const child = @import("http_child.zig");
pub const executable = @import("http_executable.zig");
pub const proxy = @import("http_proxy.zig");
pub const parse = @import("http_parse.zig");
pub const node = @import("http_node.zig");
pub const curl = @import("http_curl.zig");

pub const request_timeout_secs = types.request_timeout_secs;
pub const request_timeout_ms = types.request_timeout_ms;
pub const request_timeout_ms_value = types.request_timeout_ms_value;
pub const child_process_timeout_ms = types.child_process_timeout_ms;
pub const child_process_timeout_ms_value = types.child_process_timeout_ms_value;
pub const user_agent = types.user_agent;
pub const node_executable_env = types.node_executable_env;
pub const node_use_env_proxy_env = types.node_use_env_proxy_env;
pub const node_requirement_hint = types.node_requirement_hint;
pub const curl_requirement_hint = types.curl_requirement_hint;
pub const default_max_output_bytes = types.default_max_output_bytes;

pub const HttpResult = types.HttpResult;
pub const BatchRequest = types.BatchRequest;
pub const BatchItemOutcome = types.BatchItemOutcome;
pub const BatchItemResult = types.BatchItemResult;
pub const BatchHttpResult = types.BatchHttpResult;
pub const NodeOutcome = types.NodeOutcome;
pub const ParsedNodeHttpOutput = types.ParsedNodeHttpOutput;
pub const ChildCaptureResult = types.ChildCaptureResult;

pub const runGetJsonCommand = node.runGetJsonCommand;
pub const runBearerGetJsonCommand = node.runBearerGetJsonCommand;
pub const runGetJsonBatchCommand = node.runGetJsonBatchCommand;
pub const ensureNodeExecutableAvailable = node.ensureNodeExecutableAvailable;
pub const resolveNodeExecutableAlloc = node.resolveNodeExecutableAlloc;
pub const resolveNodeExecutableForDebugAlloc = node.resolveNodeExecutableForDebugAlloc;
pub const runGetJsonCommand = curl.runGetJsonCommand;
pub const runBearerGetJsonCommand = curl.runBearerGetJsonCommand;
pub const runGetJsonBatchCommand = curl.runGetJsonBatchCommand;
pub const ensureCurlExecutableAvailable = curl.ensureCurlExecutableAvailable;
pub const resolveCurlExecutableAlloc = curl.resolveCurlExecutableAlloc;

pub const runChildCapture = child.runChildCapture;
pub const runChildCaptureWithOutputLimit = child.runChildCaptureWithOutputLimit;
pub const runChildCaptureWithInputAndOutputLimit = child.runChildCaptureWithInputAndOutputLimit;
pub const computeBatchChildTimeoutMs = child.computeBatchChildTimeoutMs;
pub const computeBatchChildOutputLimitBytes = child.computeBatchChildOutputLimitBytes;

pub const maybeEnableNodeEnvProxy = proxy.maybeEnableNodeEnvProxy;
pub const detectNodeEnvProxySupportWithTimeout = proxy.detectNodeEnvProxySupportWithTimeout;
pub const parseNodeVersion = proxy.parseNodeVersion;
pub const nodeVersionSupportsEnvProxy = proxy.nodeVersionSupportsEnvProxy;
pub const WindowsSystemProxy = proxy.WindowsSystemProxy;
pub const deriveWindowsSystemProxyAlloc = proxy.deriveWindowsSystemProxyAlloc;

pub const ensureExecutableAvailableAlloc = executable.ensureExecutableAvailableAlloc;
pub const resolveCurlExecutableForLaunchAlloc = executable.resolveCurlExecutableForLaunchAlloc;
pub const resolveExecutablePathEntryForLaunchAlloc = executable.resolveExecutablePathEntryForLaunchAlloc;

pub const parseNodeHttpOutput = parse.parseNodeHttpOutput;
pub const parseBatchNodeHttpOutput = parse.parseBatchNodeHttpOutput;
Loading