Skip to content

Commit f4874b8

Browse files
notcheesexLoongphy
andauthored
feat: support prolite plan (#54)
* fix: recognize prolite plan Parse prolite across auth, session, registry, and usage paths. Render the CLI label as 'pro lite'. Tested: zig build run -- list Tested: zig build test * fix: unify human-readable plan labels * test: relax prolite grouped label example * docs: clarify grouped plan label rules --------- Co-authored-by: Loongphy <Loongphy@outlook.com>
1 parent 256878b commit f4874b8

12 files changed

Lines changed: 111 additions & 22 deletions

docs/api-refresh.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ After a successful `accounts/check` response:
9292

9393
Example 1:
9494

95-
- active record: `user@example.com / team #1 / account_name = null`
96-
- same grouped scope: `user@example.com / team #2 / account_name = null`
95+
- active record: `user@example.com / Team #1 / account_name = null`
96+
- same grouped scope: `user@example.com / Team #2 / account_name = null`
9797

9898
Running `codex-auth list` should issue `accounts/check`. If the API returns:
9999

@@ -104,9 +104,9 @@ Then both grouped Team records are updated.
104104

105105
Example 2:
106106

107-
- active record: `user@example.com / pro / account_name = null`
108-
- same grouped scope: `user@example.com / team #1 / account_name = null`
109-
- same grouped scope: `user@example.com / team #2 / account_name = "Old Workspace"`
107+
- active record: `user@example.com / Pro / account_name = null`
108+
- same grouped scope: `user@example.com / Team #1 / account_name = null`
109+
- same grouped scope: `user@example.com / Team #2 / account_name = "Old Workspace"`
110110

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

@@ -115,7 +115,7 @@ Running `codex-auth list` should still issue `accounts/check`, because the group
115115

116116
Then:
117117

118-
- `team #1` is filled with `Prod Workspace`
119-
- `team #2` is overwritten from `Old Workspace` to `Sandbox Workspace`
118+
- `Team #1` is filled with `Prod Workspace`
119+
- `Team #2` is overwritten from `Old Workspace` to `Sandbox Workspace`
120120

121121
The same grouped-scope rule also applies to synchronous `list` / pre-selection `switch` refreshes and to the auto-switch daemon.

docs/implement.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,11 @@ Latest rollout `.jsonl` rate limit record shape (from an `event_msg` + `token_co
293293
- the top-level email line is a header only
294294
- child rows are the selectable accounts
295295
- alias takes precedence for the child label
296-
- otherwise the child label is the plan name (`team`, `plus`, etc.)
297-
- repeated plans under the same email are rendered as stable numbered labels like `team #1`, `team #2`
296+
- otherwise the child label is the human-readable plan name (`Team`, `Plus`, `Pro Lite`, etc.)
297+
- workspace-style duplicate plans may use stable numbered labels like `Team #1`, `Team #2`
298+
- non-workspace duplicate plans (`Free`, `Plus`, `Pro`, `Pro Lite`) do not use `#1` / `#2`; they should use another disambiguator such as an account or user suffix
298299
- Single-account emails still render as one flat row; when an alias is set, that row shows `(alias)email`.
299300
- The switch/remove UI shows `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST`, and preserves grouped child indentation.
300301
- Usage limit cells show remaining percent plus reset time: `NN% (HH:MM)` for same-day resets, or `NN% (HH:MM on D Mon)` when the reset is on a different day.
301302
- `LAST ACTIVITY` is derived from `last_usage_at` and rendered as a relative time like `Now` or `2m ago`.
302-
- `PLAN` comes from the auth claim when available, and falls back to the last usage snapshot's `plan_type` (e.g. `free`, `plus`, `team`).
303+
- `PLAN` comes from the auth claim when available, and falls back to the last usage snapshot's `plan_type` (for example raw values like `free`, `plus`, `prolite`, `team` are shown as `Free`, `Plus`, `Pro Lite`, `Team`).

src/auth.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ fn base64UrlNoPadDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
275275
fn parsePlanType(s: []const u8) registry.PlanType {
276276
if (std.ascii.eqlIgnoreCase(s, "free")) return .free;
277277
if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus;
278+
if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite;
278279
if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro;
279280
if (std.ascii.eqlIgnoreCase(s, "team")) return .team;
280281
if (std.ascii.eqlIgnoreCase(s, "business")) return .business;

src/cli.zig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1821,7 +1821,7 @@ fn buildSwitchRows(allocator: std.mem.Allocator, reg: *registry.Registry) !Switc
18211821
for (display.rows, 0..) |display_row, i| {
18221822
if (display_row.account_index) |account_idx| {
18231823
const rec = reg.accounts.items[account_idx];
1824-
const plan = if (registry.resolvePlan(&rec)) |p| @tagName(p) else "-";
1824+
const plan = if (registry.resolvePlan(&rec)) |p| registry.planLabel(p) else "-";
18251825
const rate_5h = resolveRateWindow(rec.last_usage, 300, true);
18261826
const rate_week = resolveRateWindow(rec.last_usage, 10080, false);
18271827
const rate_5h_str = try formatRateLimitSwitchAlloc(allocator, rate_5h);
@@ -1885,7 +1885,7 @@ fn buildSwitchRowsFromIndices(
18851885
for (display.rows, 0..) |display_row, i| {
18861886
if (display_row.account_index) |account_idx| {
18871887
const rec = reg.accounts.items[account_idx];
1888-
const plan = if (registry.resolvePlan(&rec)) |p| @tagName(p) else "-";
1888+
const plan = if (registry.resolvePlan(&rec)) |p| registry.planLabel(p) else "-";
18891889
const rate_5h = resolveRateWindow(rec.last_usage, 300, true);
18901890
const rate_week = resolveRateWindow(rec.last_usage, 10080, false);
18911891
const rate_5h_str = try formatRateLimitSwitchAlloc(allocator, rate_5h);

src/display_rows.zig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,13 @@ fn lessThanByDisplayOrder(ctx: SortContext, lhs: usize, rhs: usize) bool {
139139
fn planSortRank(plan: ?registry.PlanType) u8 {
140140
return switch (plan orelse .unknown) {
141141
.team, .business, .enterprise, .edu => 0,
142-
.free, .plus, .pro => 1,
142+
.free, .plus, .prolite, .pro => 1,
143143
else => 2,
144144
};
145145
}
146146

147147
fn displayPlan(rec: *const registry.AccountRecord) []const u8 {
148-
return if (registry.resolvePlan(rec)) |plan| @tagName(plan) else "-";
148+
return if (registry.resolvePlan(rec)) |plan| registry.planLabel(plan) else "-";
149149
}
150150

151151
fn isActive(reg: *const registry.Registry, account_idx: usize) bool {

src/format.zig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ fn colorEnabled() bool {
1919
}
2020

2121
fn planDisplay(rec: *const registry.AccountRecord, missing: []const u8) []const u8 {
22-
if (registry.resolvePlan(rec)) |p| return @tagName(p);
22+
if (registry.resolvePlan(rec)) |p| return registry.planLabel(p);
2323
return missing;
2424
}
2525

@@ -683,5 +683,5 @@ test "writeAccountsTable shows zero-padded row numbers for selectable accounts"
683683

684684
const output = writer.buffered();
685685
try std.testing.expect(std.mem.indexOf(u8, output, "01 Als's Workspace") != null);
686-
try std.testing.expect(std.mem.indexOf(u8, output, "02 free") != null);
686+
try std.testing.expect(std.mem.indexOf(u8, output, "02 Free") != null);
687687
}

src/registry.zig

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const c_time = @cImport({
55
@cInclude("time.h");
66
});
77

8-
pub const PlanType = enum { free, plus, pro, team, business, enterprise, edu, unknown };
8+
pub const PlanType = enum { free, plus, prolite, pro, team, business, enterprise, edu, unknown };
99
pub const AuthMode = enum { chatgpt, apikey };
1010
pub const current_schema_version: u32 = 3;
1111
pub const min_supported_schema_version: u32 = 2;
@@ -84,6 +84,20 @@ pub fn resolvePlan(rec: *const AccountRecord) ?PlanType {
8484
return null;
8585
}
8686

87+
pub fn planLabel(plan: PlanType) []const u8 {
88+
return switch (plan) {
89+
.free => "Free",
90+
.plus => "Plus",
91+
.prolite => "Pro Lite",
92+
.pro => "Pro",
93+
.team => "Team",
94+
.business => "Business",
95+
.enterprise => "Enterprise",
96+
.edu => "Edu",
97+
.unknown => "Unknown",
98+
};
99+
}
100+
87101
pub const Registry = struct {
88102
schema_version: u32,
89103
active_account_key: ?[]u8,
@@ -2512,6 +2526,7 @@ const RegistryOut = struct {
25122526
fn parsePlanType(s: []const u8) ?PlanType {
25132527
if (std.mem.eql(u8, s, "free")) return .free;
25142528
if (std.mem.eql(u8, s, "plus")) return .plus;
2529+
if (std.mem.eql(u8, s, "prolite")) return .prolite;
25152530
if (std.mem.eql(u8, s, "pro")) return .pro;
25162531
if (std.mem.eql(u8, s, "team")) return .team;
25172532
if (std.mem.eql(u8, s, "business")) return .business;

src/sessions.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ fn parseCredits(allocator: std.mem.Allocator, parsed: UsageCreditsJson) registry
450450
fn parsePlanType(s: []const u8) registry.PlanType {
451451
if (std.ascii.eqlIgnoreCase(s, "free")) return .free;
452452
if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus;
453+
if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite;
453454
if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro;
454455
if (std.ascii.eqlIgnoreCase(s, "team")) return .team;
455456
if (std.ascii.eqlIgnoreCase(s, "business")) return .business;

src/tests/display_rows_test.zig

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ test "Scenario: Given same email with two team accounts and one plus account whe
5757
try std.testing.expect(rows.rows.len == 4);
5858
try std.testing.expect(rows.rows[0].account_index == null);
5959
try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "user@example.com"));
60-
try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "team #1"));
60+
try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "Team #1"));
6161
try std.testing.expect(rows.rows[1].is_active);
62-
try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "team #2"));
63-
try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "plus"));
62+
try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Team #2"));
63+
try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "Plus"));
6464
try std.testing.expect(rows.selectable_row_indices.len == 3);
6565
}
6666

@@ -80,6 +80,23 @@ test "Scenario: Given grouped accounts with aliases when building display rows t
8080
try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "backup") or std.mem.eql(u8, rows.rows[2].account_cell, "work"));
8181
}
8282

83+
test "Scenario: Given grouped accounts with a prolite record when building display rows then labels use Pro Lite wording" {
84+
const gpa = std.testing.allocator;
85+
var reg = makeRegistry();
86+
defer reg.deinit(gpa);
87+
88+
try appendAccount(gpa, &reg, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "", .prolite);
89+
try appendAccount(gpa, &reg, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "", .team);
90+
91+
var rows = try display_rows.buildDisplayRows(gpa, &reg, null);
92+
defer rows.deinit(gpa);
93+
94+
try std.testing.expectEqual(@as(usize, 3), rows.rows.len);
95+
try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "user@example.com"));
96+
try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "Team"));
97+
try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Pro Lite"));
98+
}
99+
83100
test "Scenario: Given same-email accounts filtered down to one row when building display rows then singleton is decided from the rendered subset" {
84101
const gpa = std.testing.allocator;
85102
var reg = makeRegistry();
@@ -144,7 +161,7 @@ test "Scenario: Given mixed singleton and grouped accounts when building display
144161
try std.testing.expect(rows.rows[1].account_index == null);
145162
try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "user@example.com"));
146163
try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "work (Primary Workspace)"));
147-
try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "plus"));
164+
try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "Plus"));
148165
}
149166

150167
test "Scenario: Given grouped accounts with account names when building display rows then child labels use the same precedence" {
@@ -168,5 +185,5 @@ test "Scenario: Given grouped accounts with account names when building display
168185
(std.mem.eql(u8, rows.rows[1].account_cell, "Backup Workspace") and
169186
std.mem.eql(u8, rows.rows[2].account_cell, "work (Primary Workspace)")),
170187
);
171-
try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "plus"));
188+
try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "Plus"));
172189
}

src/tests/registry_test.zig

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,34 @@ test "registry save/load" {
218218
try std.testing.expect(loaded.accounts.items[0].account_name == null);
219219
}
220220

221+
test "plan labels are human-readable while registry stores raw plan values" {
222+
const gpa = std.testing.allocator;
223+
var tmp = std.testing.tmpDir(.{});
224+
defer tmp.cleanup();
225+
226+
const codex_home = try tmp.dir.realpathAlloc(gpa, ".");
227+
defer gpa.free(codex_home);
228+
try tmp.dir.makePath("accounts");
229+
230+
var reg = makeEmptyRegistry();
231+
defer reg.deinit(gpa);
232+
233+
try reg.accounts.append(gpa, try makeAccountRecord(gpa, "label@example.com", "", .prolite, .chatgpt, 1));
234+
try registry.saveRegistry(gpa, codex_home, &reg);
235+
236+
const registry_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" });
237+
defer gpa.free(registry_path);
238+
const saved = try bdd.readFileAlloc(gpa, registry_path);
239+
defer gpa.free(saved);
240+
241+
try std.testing.expect(std.mem.indexOf(u8, saved, "\"plan\": \"prolite\"") != null);
242+
try std.testing.expect(std.mem.indexOf(u8, saved, "Pro Lite") == null);
243+
try std.testing.expectEqualStrings("Free", registry.planLabel(.free));
244+
try std.testing.expectEqualStrings("Plus", registry.planLabel(.plus));
245+
try std.testing.expectEqualStrings("Pro Lite", registry.planLabel(.prolite));
246+
try std.testing.expectEqualStrings("Team", registry.planLabel(.team));
247+
}
248+
221249
test "registry load defaults missing account_name field to null" {
222250
const gpa = std.testing.allocator;
223251
var tmp = std.testing.tmpDir(.{});

0 commit comments

Comments
 (0)