//! Charm Hyper usage feed implementation.
//!
//! Mirrors prime-agent:packages/ai/src/utils/usage/providers/hyper.ts field for field.
const std = @import("std");
const usage = @import("../usage.zig");
const iso8601 = @import("../iso8601.zig");
pub const HYPER_TEAMS_URL = "https://hyper.charm.land/v1/teams";
pub const ParseBillingResult = struct {
planType: ?[]const u8 = null,
limits: []const usage.UsageLimit = &[_]usage.UsageLimit{},
balance: ?[]const u8 = null,
};
pub fn parseHyperBilling(
alloc: std.mem.Allocator,
payload: ?std.json.Value,
now_ms: i64,
) !ParseBillingResult {
const root = usage.asObject(payload);
const plan = if (root) |r| usage.asObject(r.get("plan")) else null;
const balances = if (root) |r| usage.asObject(r.get("balances")) else null;
const plan_balances = if (balances) |b| usage.asObject(b.get("plan")) else null;
const total_balances = if (balances) |b| usage.asObject(b.get("total")) else null;
const refresh = if (root) |r| usage.asObject(r.get("hypercredit_refresh")) else null;
const plan_name = if (plan) |p| usage.asString(p.get("name")) else null;
const plan_type_raw = if (plan) |p| usage.asString(p.get("type")) else null;
const plan_type: ?[]const u8 = if (plan_name) |pn| blk: {
const trimmed = std.mem.trim(u8, pn, " \t\r\n");
if (trimmed.len == 0) break :blk null;
const lower = try alloc.alloc(u8, trimmed.len);
break :blk std.ascii.lowerString(lower, trimmed);
} else if (plan_type_raw) |ptr| blk: {
const trimmed = std.mem.trim(u8, ptr, " \t\r\n");
if (trimmed.len == 0) break :blk null;
const lower = try alloc.alloc(u8, trimmed.len);
break :blk std.ascii.lowerString(lower, trimmed);
} else null;
const limit = if (plan) |p| usage.asNumber(p.get("hypercredits")) else null;
const remaining = if (plan_balances) |pb| usage.asNumber(pb.get("hypercredits")) else null;
var used_percent: ?f64 = null;
if (limit != null and limit.? > 0.0 and remaining != null) {
const used = @max(0.0, limit.? - remaining.?);
used_percent = @max(0.0, @min(100.0, @round((used / limit.?) * 1000.0) / 10.0));
} else if (remaining != null and remaining.? <= 0.0) {
used_percent = 100.0;
}
const resets_at: ?i64 = if (refresh) |rf| (if (usage.asString(rf.get("next_at"))) |s| iso8601.parseIso8601(s) else null) else null;
const last_at: ?i64 = if (refresh) |rf| (if (usage.asString(rf.get("last_at"))) |s| iso8601.parseIso8601(s) else null) else null;
var window_label: ?[]const u8 = null;
var window_seconds: ?i64 = null;
if (resets_at != null and last_at != null) {
const diff_ms = resets_at.? - last_at.?;
if (diff_ms > 0) {
const diff_sec = usage.roundToInt(i64, @as(f64, @floatFromInt(diff_ms)) / 1000.0) orelse 0;
// The refresh period is measured from its own endpoints, so it is the window.
if (diff_sec > 0) window_seconds = diff_sec;
if (diff_sec >= 86_400) {
const days = usage.roundToInt(i64, @as(f64, @floatFromInt(diff_sec)) / 86_400.0) orelse 1;
window_label = if (days == 1) "24h window" else try std.fmt.allocPrint(alloc, "{d}d window", .{days});
} else if (diff_sec > 0) {
const hours = @max(1, usage.roundToInt(i64, @as(f64, @floatFromInt(diff_sec)) / 3600.0) orelse 1);
window_label = try std.fmt.allocPrint(alloc, "{d}h window", .{hours});
}
}
} else {
window_label = try usage.epochToWindowLabel(alloc, resets_at, now_ms);
}
const root_status = if (root) |r| usage.asString(r.get("status")) else null;
const status: []const u8 = if (root_status == null or !std.mem.eql(u8, root_status.?, "active"))
"unknown"
else if (used_percent == null)
"unknown"
else if (used_percent.? >= 100.0)
"exhausted"
else if (used_percent.? >= 90.0)
"warning"
else
"ok";
const detail: ?[]const u8 = if (remaining != null and limit != null) blk: {
const rem = usage.toInt(i64, remaining.?) orelse break :blk null;
const lim = usage.toInt(i64, limit.?) orelse break :blk null;
break :blk try std.fmt.allocPrint(alloc, "{d} of {d} credits", .{ rem, lim });
} else null;
var limits = std.ArrayList(usage.UsageLimit).empty;
try limits.append(alloc, .{
.id = "hypercredits",
.label = "Primary",
.usedPercent = used_percent,
.windowLabel = window_label,
.windowSeconds = window_seconds,
.resetsAt = resets_at,
.status = status,
.detail = detail,
});
const balance: ?[]const u8 = if (total_balances) |tb| blk: {
if (usage.asString(tb.get("usd"))) |u| {
const trimmed = std.mem.trim(u8, u, " \t\r\n");
if (trimmed.len > 0) break :blk trimmed;
}
if (usage.asNumber(tb.get("usd"))) |n| {
break :blk try std.fmt.allocPrint(alloc, "{d:.2}", .{n});
}
break :blk null;
} else null;
return ParseBillingResult{
.planType = plan_type,
.limits = limits.items,
.balance = balance,
};
}
pub fn advance(
alloc: std.mem.Allocator,
state: ?*usage.State,
credential_json: []const u8,
responses_json: []const u8,
now_ms: i64,
) !usage.Outcome {
const cred_parsed = std.json.parseFromSlice(std.json.Value, alloc, credential_json, .{}) catch {
return .{ .report = .{ .error_message = "invalid credential JSON" } };
};
const cred_root = usage.asObject(cred_parsed.value) orelse {
return .{ .report = .{ .error_message = "invalid credential JSON" } };
};
const kind = usage.asString(cred_root.get("kind")) orelse "";
const is_oauth = std.mem.eql(u8, kind, "oauth");
const is_api_key = std.mem.eql(u8, kind, "apiKey");
if (!is_oauth and !is_api_key) {
return .{ .report = .{ .error_message = "hyper needs an apiKey or OAuth credential" } };
}
const token = if (is_oauth)
usage.asString(cred_root.get("access")) orelse ""
else
usage.asString(cred_root.get("apiKey")) orelse "";
const cred_account_id = usage.stringOf(cred_root.get("accountId"));
const cred_email = usage.stringOf(cred_root.get("email"));
const resp_parsed = std.json.parseFromSlice(std.json.Value, alloc, responses_json, .{}) catch {
return .{ .report = .{ .error_message = "invalid responses JSON" } };
};
const resp_array = usage.asArray(resp_parsed.value) orelse {
return .{ .report = .{ .error_message = "invalid responses JSON" } };
};
var auth_hdr_list = std.ArrayList([2][]const u8).empty;
const auth_value = try std.fmt.allocPrint(alloc, "Bearer {s}", .{token});
try auth_hdr_list.append(alloc, .{ "Authorization", auth_value });
// Round 1: Fetch teams
if (resp_array.items.len == 0) {
const requests = try alloc.alloc(usage.UsageRequest, 1);
requests[0] = .{
.method = "GET",
.url = HYPER_TEAMS_URL,
.headers = auth_hdr_list.items,
};
return .{ .requests = requests };
}
// Round 2: the answer is either the teams list or already a billing body. The reference
// (hyper.ts) distinguishes them by shape, not by round number, so a caller that hands the
// billing body in as the only response gets a report instead of "no teams found".
const first_payload = switch (try usage.parseFirstResponse(alloc, resp_array, "teams body is not JSON")) {
.ok => |f| f,
.err => |msg| return .{ .report = .{ .error_message = msg } },
};
const first_object = usage.asObject(first_payload.parsed.value);
const is_billing_response = first_object != null and
(first_object.?.get("plan") != null or first_object.?.get("balances") != null);
if (!is_billing_response and resp_array.items.len == 1) {
const teams_root = first_object;
const items = if (teams_root) |tr| usage.asArray(tr.get("items")) else null;
if (items == null or items.?.items.len == 0) {
return .{ .report = .{ .error_message = "no hyper teams found" } };
}
var selected_team: ?std.json.ObjectMap = null;
if (cred_account_id) |caid| {
for (items.?.items) |it| {
const o = usage.asObject(it) orelse continue;
if (usage.asString(o.get("id"))) |id| {
if (std.mem.eql(u8, id, caid)) {
selected_team = o;
break;
}
}
}
}
if (selected_team == null) {
selected_team = usage.asObject(items.?.items[0]);
}
const team_id = if (selected_team) |st| usage.asString(st.get("id")) else null;
const team_name = if (selected_team) |st| usage.asString(st.get("name")) else null;
if (team_id == null or team_id.?.len == 0) {
return .{ .report = .{ .error_message = "invalid team in hyper response" } };
}
if (state) |s| {
try s.setHyperTeam(team_id.?, team_name);
}
const billing_url = try std.fmt.allocPrint(alloc, "{s}/{s}/billing", .{ HYPER_TEAMS_URL, team_id.? });
const members_url = try std.fmt.allocPrint(alloc, "{s}/{s}/members", .{ HYPER_TEAMS_URL, team_id.? });
const requests = try alloc.alloc(usage.UsageRequest, 2);
requests[0] = .{
.method = "GET",
.url = billing_url,
.headers = auth_hdr_list.items,
};
requests[1] = .{
.method = "GET",
.url = members_url,
.headers = auth_hdr_list.items,
};
return .{ .requests = requests };
}
// Round 3: Billing & members responses handling. The billing body is whichever response the
// shaping above identified, so it is already parsed.
const parsed_billing = try parseHyperBilling(alloc, first_payload.parsed.value, now_ms);
var member_email: ?[]const u8 = null;
if (resp_array.items.len >= 2) {
const resp1_obj = usage.asObject(resp_array.items[1]);
if (resp1_obj) |r1| {
const st1 = usage.toInt(i64, usage.asNumber(r1.get("status")));
if (st1 != null and st1.? == 200) {
if (usage.asString(r1.get("body"))) |b1_str| {
if (std.json.parseFromSlice(std.json.Value, alloc, b1_str, .{})) |b1_parsed| {
if (usage.asObject(b1_parsed.value)) |b1_obj| {
if (usage.asArray(b1_obj.get("items"))) |mitems| {
if (mitems.items.len > 0) {
if (usage.asObject(mitems.items[0])) |m0| {
if (usage.asString(m0.get("email"))) |em| {
const trimmed = std.mem.trim(u8, em, " \t\r\n");
if (trimmed.len > 0) member_email = trimmed;
}
}
}
}
}
} else |_| {}
}
}
}
}
const cached_id = if (state) |s| s.hyper_team_id else null;
const cached_name = if (state) |s| s.hyper_team_name else null;
const account_id = cred_account_id orelse cached_id;
const email = member_email orelse cred_email;
const notes: []const []const u8 = if (cached_name) |tn| blk: {
const arr = try alloc.alloc([]const u8, 1);
arr[0] = try std.fmt.allocPrint(alloc, "Team: {s}", .{tn});
break :blk arr;
} else &[_][]const u8{};
return .{
.report = .{
.planType = parsed_billing.planType,
.accountId = account_id,
.email = email,
.limits = parsed_billing.limits,
.balance = parsed_billing.balance,
.notes = notes,
.error_message = null,
},
};
}