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
2 changes: 1 addition & 1 deletion docs/auto-switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This document is the single source of truth for `codex-auth` background auto-switch behavior.

It does not describe the foreground `codex-auth switch --live` picker mode. That live picker mode uses its own immediate display-driven trigger rules and does not read `auto_switch.threshold_5h_percent` or `auto_switch.threshold_weekly_percent`.
It does not describe the foreground `codex-auth switch --live` picker mode. That live picker mode has its own immediate display-driven switching implementation. It reads the same threshold config fields, but it does not share the background watcher candidate scoring or switching path.

## Commands and Stored Config

Expand Down
8 changes: 4 additions & 4 deletions docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ Managed files:
## Registry Compatibility

- `registry.json.schema_version` is the on-disk migration gate.
- `schema_version = 3` is the current layout with record-keyed snapshots, active-account activation timestamps, and per-account local rollout dedupe.
- `version = 2` registries using `active_email` and email-keyed snapshots are migrated to schema `3`.
- Current-layout files that still use the top-level `version = 3` key are rewritten to `schema_version = 3`.
- `schema_version = 4` is the current layout with record-keyed snapshots, active-account activation timestamps, per-account local rollout dedupe, and default auto-switch thresholds reset to `1`.
- `version = 2` registries using `active_email` and email-keyed snapshots are migrated to the current schema.
- Current-layout files that still use the top-level `version = 3` key are rewritten to `schema_version = 4`.
- Loading a supported older schema performs the migration in memory and rewrites `registry.json` in the current format.
- Loading a newer `schema_version` is rejected with `UnsupportedRegistryVersion`.
- Saving always rewrites `registry.json` into the current schema `3` field set.
- Saving always rewrites `registry.json` into the current schema `4` field set.

See [docs/schema-migration.md](./schema-migration.md) for versioning policy and migration rules.

Expand Down
9 changes: 6 additions & 3 deletions docs/schema-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r
- `codex-auth` keeps a single `registry.json`; feature state such as `auto_switch` and `api` stays in that file.
- The latest binary supports every released schema. Right now that means:
- legacy `version = 2`
- current `schema_version = 3`
- The current binary also accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them once to normalized `schema_version = 3`.
- current `schema_version = 4`
- The current binary also accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them once to normalized `schema_version = 4`.
- If the binary sees a newer `schema_version` than it understands, it fails with `UnsupportedRegistryVersion` and must not write the file.

## Upgrade Behavior

- User-visible behavior is always “upgrade directly to the latest supported schema”.
- Internally, migrations are implemented as a chain of `Vn -> Vn+1` steps.
- In the current code, supported automatic migration is `version = 2 -> schema_version = 3`, then the file is rewritten once as schema `3`.
- In the current code, supported automatic migration is `version = 2 -> schema_version = 4`; older current-layout schema `3` files are rewritten once as schema `4`.
- Users are not expected to install intermediate `codex-auth` versions.

## Released Schemas
Expand All @@ -39,6 +39,9 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r
- Current top-level `api` block
- Per-account `account_key`
- Each account also stores `chatgpt_account_id` and `chatgpt_user_id`
- `schema_version = 4`
- Same layout as schema `3`
- Migrates existing `auto_switch.threshold_5h_percent` and `auto_switch.threshold_weekly_percent` values back to the new default `1`

## When To Bump `schema_version`

Expand Down
12 changes: 12 additions & 0 deletions src/cli/live_switch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,20 @@ pub fn runSwitchLiveActions(

while (true) {
if (try controller.refresh.maybe_take_updated_display(controller.refresh.context)) |updated| {
const previous_active_account_key = if (current_display.reg.active_account_key) |key|
try allocator.dupe(u8, key)
else
null;
defer if (previous_active_account_key) |key| allocator.free(key);

current_display.deinit(allocator);
current_display = updated;
if (current_display.reg.active_account_key) |active_account_key| {
if (previous_active_account_key == null or !std.mem.eql(u8, previous_active_account_key.?, active_account_key)) {
replaceOptionalOwnedString(allocator, &selected_account_key, try allocator.dupe(u8, active_account_key));
follow_selection = true;
}
}
auto_switch_state.noteRefreshedDisplay();
needs_render = true;
rows_cache.invalidate(allocator);
Expand Down
53 changes: 25 additions & 28 deletions src/cli/picker_auto.zig
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ fn numericUsageOverrideStatus(usage_override: ?[]const u8) ?u16 {
return std.fmt.parseInt(u16, value, 10) catch null;
}

fn accountHasExhaustedUsage(rec: *const registry.AccountRecord, now: i64) bool {
fn accountCrossesSwitchThreshold(reg: *const registry.Registry, rec: *const registry.AccountRecord, now: i64) bool {
const rate_5h = resolveRateWindow(rec.last_usage, 300, true);
const rate_week = resolveRateWindow(rec.last_usage, 10080, false);
const rem_5h = registry.remainingPercentAt(rate_5h, now);
const rem_week = registry.remainingPercentAt(rate_week, now);
return (rem_5h != null and rem_5h.? == 0) or (rem_week != null and rem_week.? == 0);
return (rem_5h != null and rem_5h.? <= @as(i64, reg.auto_switch.threshold_5h_percent)) or
(rem_week != null and rem_week.? <= @as(i64, reg.auto_switch.threshold_weekly_percent));
}

fn shouldAutoSwitchActiveAccount(display: SwitchSelectionDisplay, now: i64) bool {
Expand All @@ -32,22 +33,26 @@ fn shouldAutoSwitchActiveAccount(display: SwitchSelectionDisplay, now: i64) bool
return status_code != 200;
}

return accountHasExhaustedUsage(&display.reg.accounts.items[active_idx], now);
return accountCrossesSwitchThreshold(display.reg, &display.reg.accounts.items[active_idx], now);
}

fn autoSwitchCandidateIsBetter(
candidate_score: ?i64,
candidate_last_usage_at: ?i64,
best_score: ?i64,
best_last_usage_at: i64,
) bool {
if (candidate_score != null and best_score == null) return true;
if (candidate_score == null and best_score != null) return false;
if (candidate_score != null and best_score != null and candidate_score.? != best_score.?) {
return candidate_score.? > best_score.?;
}
fn resetDistanceSeconds(window: ?registry.RateLimitWindow, now: i64) i64 {
const resets_at = if (window) |value| value.resets_at orelse return std.math.maxInt(i64) else return std.math.maxInt(i64);
return @max(resets_at - now, 0);
}

fn resetDistanceForMinutes(usage: ?registry.RateLimitSnapshot, minutes: i64, fallback_primary: bool, now: i64) i64 {
return resetDistanceSeconds(resolveRateWindow(usage, minutes, fallback_primary), now);
}

fn autoSwitchCandidateIsBetter(candidate: *const registry.AccountRecord, best: *const registry.AccountRecord, now: i64) bool {
const candidate_5h_reset = resetDistanceForMinutes(candidate.last_usage, 300, true, now);
const best_5h_reset = resetDistanceForMinutes(best.last_usage, 300, true, now);
if (candidate_5h_reset != best_5h_reset) return candidate_5h_reset < best_5h_reset;

return (candidate_last_usage_at orelse -1) > best_last_usage_at;
const candidate_weekly_reset = resetDistanceForMinutes(candidate.last_usage, 10080, false, now);
const best_weekly_reset = resetDistanceForMinutes(best.last_usage, 10080, false, now);
return candidate_weekly_reset < best_weekly_reset;
}

fn bestAutoSwitchCandidateSelectableIndex(
Expand All @@ -58,25 +63,17 @@ fn bestAutoSwitchCandidateSelectableIndex(
const active_account_key = reg.active_account_key orelse return null;

var best_selectable_idx: ?usize = null;
var best_score: ?i64 = null;
var best_last_usage_at: i64 = -1;
var best_account_idx: ?usize = null;

for (rows.selectable_row_indices, 0..) |row_idx, selectable_idx| {
const account_idx = rows.items[row_idx].account_index orelse continue;
const rec = &reg.accounts.items[account_idx];
if (std.mem.eql(u8, rec.account_key, active_account_key)) continue;
if (accountHasExhaustedUsage(rec, now)) continue;

const candidate_score = registry.usageScoreAt(rec.last_usage, now);
if (best_selectable_idx == null or autoSwitchCandidateIsBetter(
candidate_score,
rec.last_usage_at,
best_score,
best_last_usage_at,
)) {
if (accountCrossesSwitchThreshold(reg, rec, now)) continue;

if (best_account_idx == null or autoSwitchCandidateIsBetter(rec, &reg.accounts.items[best_account_idx.?], now)) {
best_selectable_idx = selectable_idx;
best_score = candidate_score;
best_last_usage_at = rec.last_usage_at orelse -1;
best_account_idx = account_idx;
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/registry/common.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ const c_time = @cImport({

pub const PlanType = enum { free, plus, prolite, pro, team, business, enterprise, edu, unknown };
pub const AuthMode = enum { chatgpt, apikey };
pub const current_schema_version: u32 = 3;
pub const current_schema_version: u32 = 4;
pub const min_supported_schema_version: u32 = 2;
pub const default_auto_switch_threshold_5h_percent: u8 = 10;
pub const default_auto_switch_threshold_weekly_percent: u8 = 5;
pub const default_auto_switch_threshold_5h_percent: u8 = 1;
pub const default_auto_switch_threshold_weekly_percent: u8 = 1;
pub const account_name_refresh_lock_file_name = "account-name-refresh.lock";
pub const private_file_permissions: std.Io.File.Permissions = switch (builtin.os.tag) {
.windows => .default_file,
Expand Down
10 changes: 9 additions & 1 deletion src/registry/storage.zig
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,13 @@ fn detectSchemaVersion(root_obj: std.json.ObjectMap) u32 {
return schemaVersionFieldValue(root_obj) orelse if (root_obj.get("active_email") != null) 2 else current_schema_version;
}

fn applySchemaMigrations(reg: *Registry, loaded_schema_version: u32) void {
if (loaded_schema_version < 4) {
reg.auto_switch.threshold_5h_percent = common.default_auto_switch_threshold_5h_percent;
reg.auto_switch.threshold_weekly_percent = common.default_auto_switch_threshold_weekly_percent;
}
}

fn logUnsupportedRegistryVersion(version_value: u32) void {
if (builtin.is_test) return;
std.log.err(
Expand Down Expand Up @@ -448,7 +455,7 @@ pub fn loadRegistry(allocator: std.mem.Allocator, codex_home: []const u8) !Regis
(schema_version == current_schema_version and currentLayoutNeedsRewrite(root_obj));
var reg = switch (schema_version) {
2 => try loadLegacyRegistryV2(allocator, codex_home, root_obj),
3 => try loadCurrentRegistry(allocator, root_obj),
3, 4 => try loadCurrentRegistry(allocator, root_obj),
else => {
std.log.err(
"registry schema_version {d} is older than the minimum supported {d}; use an intermediate codex-auth release or import --purge",
Expand All @@ -458,6 +465,7 @@ pub fn loadRegistry(allocator: std.mem.Allocator, codex_home: []const u8) !Regis
},
};
errdefer reg.deinit(allocator);
applySchemaMigrations(&reg, schema_version);

if (needs_rewrite) {
try saveRegistry(allocator, codex_home, &reg);
Expand Down
13 changes: 13 additions & 0 deletions tests/auto_daemon_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ test "Scenario: Given weekly remaining below threshold when checking current the
const gpa = std.testing.allocator;
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.threshold_weekly_percent = 5;

try appendAccountWithUsage(gpa, &reg, "active@example.com", .{
.primary = .{ .used_percent = 20.0, .window_minutes = 300, .resets_at = null },
Expand Down Expand Up @@ -663,6 +664,7 @@ test "Scenario: Given missing window_minutes in the primary slot when checking c
const gpa = std.testing.allocator;
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.threshold_5h_percent = 10;

try appendAccountWithUsage(gpa, &reg, "active@example.com", .{
.primary = .{ .used_percent = 95.0, .window_minutes = null, .resets_at = null },
Expand Down Expand Up @@ -740,6 +742,7 @@ test "Scenario: Given better candidate when auto switch runs then auth and activ
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 10;

try appendAccountWithUsage(gpa, &reg, "low@example.com", .{
.primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = null },
Expand Down Expand Up @@ -791,6 +794,7 @@ test "Scenario: Given API mode and unknown candidate usage when auto switching t
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 10;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "low@example.com", .{
Expand Down Expand Up @@ -845,6 +849,7 @@ test "Scenario: Given API mode and poor refreshed candidate when auto switching
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 10;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "low@example.com", .{
Expand Down Expand Up @@ -891,6 +896,7 @@ test "Scenario: Given repeated daemon candidate refresh attempts within cooldown
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 2;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "low@example.com", .{
Expand Down Expand Up @@ -944,6 +950,7 @@ test "Scenario: Given switch-time candidate validation returns non-200 then that
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 2;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "active@example.com", .{
Expand Down Expand Up @@ -990,6 +997,7 @@ test "Scenario: Given switch-time candidate validation returns 200 without windo
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 2;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "active@example.com", .{
Expand Down Expand Up @@ -1036,6 +1044,7 @@ test "Scenario: Given a candidate is rejected by API validation then it stays re
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 10;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "active@example.com", .{
Expand Down Expand Up @@ -1086,6 +1095,7 @@ test "Scenario: Given switch-time candidate validation reports missing auth then
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 2;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "active@example.com", .{
Expand Down Expand Up @@ -1132,6 +1142,7 @@ test "Scenario: Given switch-time candidate validation gets no response then the
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 2;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "active@example.com", .{
Expand Down Expand Up @@ -1178,6 +1189,7 @@ test "Scenario: Given daemon api mode and an api-key candidate when auto switchi
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 2;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "active@example.com", .{
Expand Down Expand Up @@ -1293,6 +1305,7 @@ test "Scenario: Given stale top candidates when daemon switches then it validate
var reg = fixtures.makeEmptyRegistry();
defer reg.deinit(gpa);
reg.auto_switch.enabled = true;
reg.auto_switch.threshold_5h_percent = 10;
reg.api.usage = true;

try appendAccountWithUsage(gpa, &reg, "active@example.com", .{
Expand Down
54 changes: 50 additions & 4 deletions tests/cli_picker_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,23 @@ fn testUsageSnapshot(now: i64, used_5h: f64, used_weekly: f64) registry.RateLimi
};
}

fn testUsageSnapshotWithResets(now: i64, used_5h: f64, used_weekly: f64, reset_5h_seconds: i64, reset_weekly_seconds: i64) registry.RateLimitSnapshot {
return .{
.primary = .{
.used_percent = used_5h,
.window_minutes = 300,
.resets_at = now + reset_5h_seconds,
},
.secondary = .{
.used_percent = used_weekly,
.window_minutes = 10080,
.resets_at = now + reset_weekly_seconds,
},
.credits = null,
.plan_type = .pro,
};
}

fn testMutableString(comptime value: []const u8) []u8 {
return @constCast(value);
}
Expand Down Expand Up @@ -921,7 +938,7 @@ test "Scenario: Given live switch navigation shortcuts when an account is unavai
try std.testing.expectEqualStrings("user-1::acc-1", selected_account_key.?);
}

test "Scenario: Given exhausted active usage when picking an auto-switch target then the best healthy candidate is chosen" {
test "Scenario: Given active usage at threshold when picking a live auto-switch target then nearest 5h reset wins" {
const gpa = std.testing.allocator;
var reg = makeTestRegistry();
defer reg.deinit(gpa);
Expand All @@ -932,9 +949,38 @@ test "Scenario: Given exhausted active usage when picking an auto-switch target
try appendTestAccount(gpa, &reg, "user-1::acc-2", "backup-a@example.com", "", .team);
try appendTestAccount(gpa, &reg, "user-1::acc-3", "backup-b@example.com", "", .team);
reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1");
reg.accounts.items[0].last_usage = testUsageSnapshot(now, 100, 10);
reg.accounts.items[1].last_usage = testUsageSnapshot(now, 35, 15);
reg.accounts.items[2].last_usage = testUsageSnapshot(now, 5, 8);
reg.accounts.items[0].last_usage = testUsageSnapshotWithResets(now, 99, 10, 3600, 7 * 24 * 3600);
reg.accounts.items[1].last_usage = testUsageSnapshotWithResets(now, 5, 5, 30 * 60, 60 * 60);
reg.accounts.items[2].last_usage = testUsageSnapshotWithResets(now, 50, 50, 5 * 60 + 1, 2 * 60 * 60);

var rows = try buildSwitchRowsWithUsageOverrides(gpa, &reg, null);
defer rows.deinit(gpa);
try filterErroredRowsFromSelectableIndices(gpa, &rows);

const target_key = try maybeAutoSwitchTargetKeyAlloc(gpa, .{
.reg = &reg,
.usage_overrides = null,
}, &rows);
defer if (target_key) |value| gpa.free(value);

try std.testing.expect(target_key != null);
try std.testing.expectEqualStrings("user-1::acc-3", target_key.?);
}

test "Scenario: Given equal 5h resets when picking a live auto-switch target then nearest weekly reset wins" {
const gpa = std.testing.allocator;
var reg = makeTestRegistry();
defer reg.deinit(gpa);

const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds();

try appendTestAccount(gpa, &reg, "user-1::acc-1", "active@example.com", "", .team);
try appendTestAccount(gpa, &reg, "user-1::acc-2", "backup-a@example.com", "", .team);
try appendTestAccount(gpa, &reg, "user-1::acc-3", "backup-b@example.com", "", .team);
reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1");
reg.accounts.items[0].last_usage = testUsageSnapshotWithResets(now, 99, 10, 3600, 7 * 24 * 3600);
reg.accounts.items[1].last_usage = testUsageSnapshotWithResets(now, 10, 10, 30 * 60, 6 * 60 * 60);
reg.accounts.items[2].last_usage = testUsageSnapshotWithResets(now, 90, 90, 30 * 60, 30 * 60);

var rows = try buildSwitchRowsWithUsageOverrides(gpa, &reg, null);
defer rows.deinit(gpa);
Expand Down
Loading
Loading