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
9 changes: 8 additions & 1 deletion docs/json-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,15 @@ period end.
| Input observed by the CLI | JSON plan |
|---------------------------|-----------|
| `team`, `self_serve_business_usage_based` | `business` |
| `business`, `enterprise_cbp_usage_based`, `enterprise`, `hc` | `enterprise` |
| `self_serve_business_prolite` | `business_premium` |
| `business`, `ent26`, `enterprise_cbp_usage_based`, `enterprise`, `hc` | `enterprise` |
| `enterprise_cbp_automation` | `enterprise_automation` |
| `education`, `edu` | `edu` |
| `edu_plus`, `edu_pro`, `sci`, `sci_plus`, `promax` | unchanged |

The stored `plan` value (schema version 4+) keeps these same normalized
tag names; only registries written before schema version 4 still carry
raw backend identifiers and are normalized on load.

The GUI must not repeat this mapping. When both auth and stored usage provide a
plan, the usage plan wins.
Expand Down
20 changes: 4 additions & 16 deletions src/cli/json_output.zig
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ fn writeAccount(jw: *std.json.Stringify, account: *const results.AccountView) !v
try jw.objectField("account_name");
try jw.write(account.account_name);
try jw.objectField("plan");
try writeOptionalText(jw, planName(account.plan));
// `plan` emits the normalized tier name — the enum tag, same as the
// registry file's `Stringify` output; wire aliases like `team`
// collapsed at parse time.
try writeOptionalText(jw, if (account.plan) |p| @tagName(p) else null);
try jw.objectField("auth_mode");
try writeOptionalText(jw, authModeName(account.auth_mode));
try jw.objectField("access_token_expires_at");
Expand Down Expand Up @@ -304,21 +307,6 @@ fn writeOptionalText(jw: *std.json.Stringify, value: ?[]const u8) !void {
}
}

fn planName(plan: ?registry.PlanType) ?[]const u8 {
const value = plan orelse return null;
return switch (value) {
.free => "free",
.go => "go",
.plus => "plus",
.prolite => "prolite",
.pro => "pro",
.business => "business",
.enterprise => "enterprise",
.edu => "edu",
.unknown => "unknown",
};
}

fn writeSelectorResolution(
jw: *std.json.Stringify,
resolution: *const results.SelectorResolutionView,
Expand Down
7 changes: 5 additions & 2 deletions src/cli/table_layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,12 @@ fn writeTruncated(out: *std.Io.Writer, text: []const u8, width: usize) !usize {
try out.writeAll(".");
return 1;
}
try out.writeAll(text[0..text_width.prefixByteLength(text, width - 1)]);
// A prefix ending on a word boundary would render `word .`, which reads
// like a standalone dot cell; trim the gap so the marker hugs the text.
const prefix = std.mem.trimEnd(u8, text[0..text_width.prefixByteLength(text, width - 1)], " ");
try out.writeAll(prefix);
try out.writeAll(".");
return width;
return text_width.displayWidth(prefix) + 1;
}

/// Usage cells read `N% (HH:MM on D Mon)`, `N% (HH:MM)` or a bare label such
Expand Down
2 changes: 1 addition & 1 deletion src/registry/account_ops.zig
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ pub fn hasStoredAccountName(rec: *const AccountRecord) bool {
pub fn isWorkspaceAccount(rec: *const AccountRecord) bool {
const plan = resolveDisplayPlan(rec) orelse return false;
return switch (plan) {
.business, .enterprise, .edu => true,
.business, .business_premium, .enterprise, .enterprise_automation, .edu, .edu_plus, .edu_pro, .sci, .sci_plus => true,
else => false,
};
}
Expand Down
41 changes: 38 additions & 3 deletions src/registry/common.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,32 @@ const app_runtime = @import("../core/runtime.zig");
const builtin = @import("builtin");
const account_api = @import("../api/account.zig");

pub const PlanType = enum { free, go, plus, prolite, pro, business, enterprise, edu, unknown };
/// Normalized plan tiers, shaped by upstream's display mapping rather
/// than raw wire values: upstream remaps several wire names onto the
/// same label (`team`/`self_serve_business_usage_based` → "Business",
/// `business`/`ent26`/`hc`/`enterprise`/`enterprise_cbp_usage_based` →
/// "Enterprise"), so `business` here is upstream's "Business" tier and
/// `enterprise` is upstream's "Enterprise" tier.
pub const PlanType = enum {
free,
go,
plus,
prolite,
pro,
promax,
business,
/// Upstream `self_serve_business_prolite`.
business_premium,
enterprise,
/// Upstream `enterprise_cbp_automation`.
enterprise_automation,
edu,
edu_plus,
edu_pro,
sci,
sci_plus,
unknown,
};
pub const AuthMode = enum { chatgpt, apikey };
pub const current_schema_version: u32 = 4;
pub const min_supported_schema_version: u32 = 2;
Expand Down Expand Up @@ -159,15 +184,25 @@ pub fn resolveDisplayPlan(rec: *const AccountRecord) ?PlanType {
}

pub fn planLabel(plan: PlanType) []const u8 {
// Display labels mirror upstream's /status mapping
// (codex-rs/tui/src/subscription.rs): prolite→Pro 100, pro→Pro 200,
// promax→Pro 500.
return switch (plan) {
.free => "Free",
.go => "Go",
.plus => "Plus",
.prolite => "Pro Lite",
.pro => "Pro",
.prolite => "Pro 100",
.pro => "Pro 200",
.promax => "Pro 500",
.business => "Business",
.business_premium => "Business Premium",
.enterprise => "Enterprise",
.enterprise_automation => "Enterprise (Automation)",
.edu => "Edu",
.edu_plus => "Edu Plus",
.edu_pro => "Edu Pro",
.sci => "Academic Researchers",
.sci_plus => "Academic Researchers Plus",
.unknown => "Unknown",
};
}
Expand Down
20 changes: 20 additions & 0 deletions src/registry/parse.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,53 @@ const CreditsSnapshot = common.CreditsSnapshot;
const RateLimitResetCredit = common.RateLimitResetCredit;
const ResetCreditCard = common.ResetCreditCard;

/// Wire `plan_type`/`chatgpt_plan_type` string → normalized tier,
/// mirroring upstream's KnownPlan::from_raw_value + display mapping.
pub fn normalizePlanType(s: []const u8) PlanType {
if (std.ascii.eqlIgnoreCase(s, "free")) return .free;
if (std.ascii.eqlIgnoreCase(s, "go")) return .go;
if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus;
if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite;
if (std.ascii.eqlIgnoreCase(s, "promax")) return .promax;
if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro;
if (std.ascii.eqlIgnoreCase(s, "self_serve_business_prolite")) return .business_premium;
if (std.ascii.eqlIgnoreCase(s, "team") or
std.ascii.eqlIgnoreCase(s, "self_serve_business_usage_based")) return .business;
if (std.ascii.eqlIgnoreCase(s, "business") or
std.ascii.eqlIgnoreCase(s, "ent26") or
std.ascii.eqlIgnoreCase(s, "enterprise_cbp_usage_based") or
std.ascii.eqlIgnoreCase(s, "enterprise") or
std.ascii.eqlIgnoreCase(s, "hc")) return .enterprise;
if (std.ascii.eqlIgnoreCase(s, "enterprise_cbp_automation")) return .enterprise_automation;
if (std.ascii.eqlIgnoreCase(s, "education") or std.ascii.eqlIgnoreCase(s, "edu")) return .edu;
if (std.ascii.eqlIgnoreCase(s, "edu_plus")) return .edu_plus;
if (std.ascii.eqlIgnoreCase(s, "edu_pro")) return .edu_pro;
if (std.ascii.eqlIgnoreCase(s, "sci")) return .sci;
if (std.ascii.eqlIgnoreCase(s, "sci_plus")) return .sci_plus;
return .unknown;
}

pub fn parseStoredPlanType(s: []const u8, schema_version: u32) PlanType {
if (schema_version < 4) return normalizePlanType(s);

// Stored values are enum tag names; match them before wire aliases
// so "business" reads as .business (the wire "business" normalizes
// to .enterprise instead).
if (std.ascii.eqlIgnoreCase(s, "free")) return .free;
if (std.ascii.eqlIgnoreCase(s, "go")) return .go;
if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus;
if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite;
if (std.ascii.eqlIgnoreCase(s, "promax")) return .promax;
if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro;
if (std.ascii.eqlIgnoreCase(s, "business")) return .business;
if (std.ascii.eqlIgnoreCase(s, "business_premium")) return .business_premium;
if (std.ascii.eqlIgnoreCase(s, "enterprise")) return .enterprise;
if (std.ascii.eqlIgnoreCase(s, "enterprise_automation")) return .enterprise_automation;
if (std.ascii.eqlIgnoreCase(s, "edu")) return .edu;
if (std.ascii.eqlIgnoreCase(s, "edu_plus")) return .edu_plus;
if (std.ascii.eqlIgnoreCase(s, "edu_pro")) return .edu_pro;
if (std.ascii.eqlIgnoreCase(s, "sci")) return .sci;
if (std.ascii.eqlIgnoreCase(s, "sci_plus")) return .sci_plus;
return .unknown;
}

Expand Down
34 changes: 32 additions & 2 deletions tests/registry_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -406,11 +406,19 @@ test "plan labels are human-readable while registry stores canonical plan values
defer gpa.free(saved);

try std.testing.expect(std.mem.find(u8, saved, "\"plan\": \"prolite\"") != null);
try std.testing.expect(std.mem.find(u8, saved, "Pro Lite") == null);
try std.testing.expect(std.mem.find(u8, saved, "Pro 100") == null);
try std.testing.expectEqualStrings("Free", registry.planLabel(.free));
try std.testing.expectEqualStrings("Plus", registry.planLabel(.plus));
try std.testing.expectEqualStrings("Pro Lite", registry.planLabel(.prolite));
// Labels mirror upstream /status: the pro family carries its quota tier.
try std.testing.expectEqualStrings("Pro 100", registry.planLabel(.prolite));
try std.testing.expectEqualStrings("Pro 200", registry.planLabel(.pro));
try std.testing.expectEqualStrings("Pro 500", registry.planLabel(.promax));
try std.testing.expectEqualStrings("Business", registry.planLabel(.business));
try std.testing.expectEqualStrings("Business Premium", registry.planLabel(.business_premium));
try std.testing.expectEqualStrings("Enterprise", registry.planLabel(.enterprise));
try std.testing.expectEqualStrings("Enterprise (Automation)", registry.planLabel(.enterprise_automation));
try std.testing.expectEqualStrings("Edu Plus", registry.planLabel(.edu_plus));
try std.testing.expectEqualStrings("Academic Researchers Plus", registry.planLabel(.sci_plus));
}

test "resolveDisplayPlan prefers a usage snapshot plan over the stored auth plan" {
Expand All @@ -434,10 +442,32 @@ test "resolveDisplayPlan prefers a usage snapshot plan over the stored auth plan
test "backend plan values normalize to final product plans" {
try std.testing.expectEqual(registry.PlanType.business, registry.normalizePlanType("team"));
try std.testing.expectEqual(registry.PlanType.business, registry.normalizePlanType("self_serve_business_usage_based"));
try std.testing.expectEqual(registry.PlanType.business_premium, registry.normalizePlanType("self_serve_business_prolite"));
try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("business"));
try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("ent26"));
try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("enterprise_cbp_usage_based"));
try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("hc"));
try std.testing.expectEqual(registry.PlanType.enterprise_automation, registry.normalizePlanType("enterprise_cbp_automation"));
try std.testing.expectEqual(registry.PlanType.go, registry.normalizePlanType("go"));
try std.testing.expectEqual(registry.PlanType.promax, registry.normalizePlanType("promax"));
try std.testing.expectEqual(registry.PlanType.edu_plus, registry.normalizePlanType("edu_plus"));
try std.testing.expectEqual(registry.PlanType.edu_pro, registry.normalizePlanType("edu_pro"));
try std.testing.expectEqual(registry.PlanType.sci, registry.normalizePlanType("sci"));
try std.testing.expectEqual(registry.PlanType.sci_plus, registry.normalizePlanType("sci_plus"));
}

test "stored plan parsing splits wire aliases from schema-4 tag names" {
// Pre-schema-4 files carry raw backend identifiers; schema 4+ stores
// the normalized tag names emitted by this binary.
try std.testing.expectEqual(registry.PlanType.enterprise, registry.parseStoredPlanType("business", 3));
try std.testing.expectEqual(registry.PlanType.business, registry.parseStoredPlanType("business", 4));
try std.testing.expectEqual(registry.PlanType.business, registry.parseStoredPlanType("team", 3));
try std.testing.expectEqual(registry.PlanType.unknown, registry.parseStoredPlanType("team", 4));
try std.testing.expectEqual(registry.PlanType.business_premium, registry.parseStoredPlanType("self_serve_business_prolite", 3));
try std.testing.expectEqual(registry.PlanType.business_premium, registry.parseStoredPlanType("business_premium", 4));
try std.testing.expectEqual(registry.PlanType.enterprise_automation, registry.parseStoredPlanType("enterprise_cbp_automation", 3));
try std.testing.expectEqual(registry.PlanType.enterprise_automation, registry.parseStoredPlanType("enterprise_automation", 4));
try std.testing.expectEqual(registry.PlanType.promax, registry.parseStoredPlanType("promax", 4));
}

test "registry load defaults missing account_name field to null" {
Expand Down
4 changes: 2 additions & 2 deletions tests/tui_display_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ test "Scenario: Given grouped accounts with aliases when building display rows t
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"));
}

test "Scenario: Given grouped accounts with a prolite record when building display rows then labels use Pro Lite wording" {
test "Scenario: Given grouped accounts with a prolite record when building display rows then labels use Pro 100 wording" {
const gpa = std.testing.allocator;
var reg = makeRegistry();
defer reg.deinit(gpa);
Expand All @@ -116,7 +116,7 @@ test "Scenario: Given grouped accounts with a prolite record when building displ
try std.testing.expectEqual(@as(usize, 3), rows.rows.len);
try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "user@example.com"));
try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "Business"));
try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Pro Lite"));
try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Pro 100"));
}

test "Scenario: Given a grouped account with a fresher usage plan when building display rows then labels and ordering prefer the usage plan" {
Expand Down
Loading