//! Anthropic usage feed implementation.
//!
//! Mirrors prime-agent:packages/ai/src/utils/usage/providers/anthropic.ts field for field.
const std = @import("std");
const usage = @import("../usage.zig");
const iso8601 = @import("../iso8601.zig");
pub const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
pub const ANTHROPIC_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
const OAUTH_BETA = "oauth-2025-04-20";
pub const WindowInfo = struct {
label: []const u8,
windowLabel: ?[]const u8 = null,
windowSeconds: ?i64 = null,
scope: ?[]const u8 = null,
};
const five_hours = 5 * 3600;
const one_day = 86_400;
const one_week = 7 * one_day;
/// The part of a kind that names what the window is restricted to; `all` restricts nothing.
fn kindScope(scope: []const u8) ?[]const u8 {
return if (scope.len == 0 or std.mem.eql(u8, scope, "all")) null else scope;
}
fn titleCase(alloc: std.mem.Allocator, value: []const u8) ![]const u8 {
var parts = std.mem.splitAny(u8, value, "_-");
var res = std.ArrayList(u8).empty;
var first = true;
while (parts.next()) |part| {
if (part.len == 0) continue;
if (!first) try res.append(alloc, ' ');
first = false;
try res.append(alloc, std.ascii.toUpper(part[0]));
if (part.len > 1) {
try res.appendSlice(alloc, part[1..]);
}
}
return res.items;
}
pub fn windowForKind(alloc: std.mem.Allocator, kind: []const u8) !WindowInfo {
if (std.mem.eql(u8, kind, "session")) {
return .{ .label = "Session", .windowLabel = "5h window", .windowSeconds = five_hours };
}
if (std.mem.endsWith(u8, kind, "_session")) {
const scope = kindScope(kind[0 .. kind.len - "_session".len]);
return .{ .label = "Session", .windowLabel = "5h window", .windowSeconds = five_hours, .scope = scope };
}
if (std.mem.startsWith(u8, kind, "weekly_")) {
const scope = kind["weekly_".len..];
if (std.mem.eql(u8, scope, "all")) {
return .{ .label = "Weekly", .windowLabel = "7d window", .windowSeconds = one_week };
}
const tc = try titleCase(alloc, scope);
const label = try std.fmt.allocPrint(alloc, "Weekly ({s})", .{tc});
return .{ .label = label, .windowLabel = "7d window", .windowSeconds = one_week, .scope = kindScope(scope) };
}
if (std.mem.eql(u8, kind, "weekly")) {
return .{ .label = "Weekly", .windowLabel = "7d window", .windowSeconds = one_week };
}
if (std.mem.startsWith(u8, kind, "daily_")) {
const scope = kind["daily_".len..];
const tc = try titleCase(alloc, scope);
const label = try std.fmt.allocPrint(alloc, "Daily ({s})", .{tc});
return .{ .label = label, .windowLabel = "24h window", .windowSeconds = one_day, .scope = kindScope(scope) };
}
const label = try titleCase(alloc, kind);
return .{ .label = label, .windowLabel = null };
}
pub const ParseUsageResult = struct {
limits: []const usage.UsageLimit,
notes: []const []const u8,
};
pub fn parseAnthropicUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, _now_ms: i64) !ParseUsageResult {
_ = _now_ms;
const root = usage.asObject(payload);
var notes = std.ArrayList([]const u8).empty;
var limits = std.ArrayList(usage.UsageLimit).empty;
const entries = if (root) |r| usage.asArray(r.get("limits")) else null;
if (entries) |arr| {
for (arr.items) |entry| {
const limit = usage.asObject(entry) orelse continue;
const kind = usage.asString(limit.get("kind")) orelse "unknown";
const used_percent = usage.asNumber(limit.get("percent"));
const win = try windowForKind(alloc, kind);
const severity = usage.asString(limit.get("severity"));
const status = if (used_percent == null)
"unknown"
else if (severity != null and std.mem.eql(u8, severity.?, "critical"))
"warning"
else
usage.statusFor(used_percent);
const final_status = if (used_percent != null and used_percent.? >= 100.0)
"exhausted"
else
status;
const resets_at_str = usage.asString(limit.get("resets_at"));
const resets_at_ms: ?i64 = if (resets_at_str) |s| iso8601.parseIso8601(s) else null;
try limits.append(alloc, .{
.id = kind,
.label = win.label,
.usedPercent = used_percent,
.windowLabel = win.windowLabel,
.windowSeconds = win.windowSeconds,
.resetsAt = resets_at_ms,
// The body's own scope wins over the one read from the kind.
.scope = usage.stringOf(limit.get("scope")) orelse win.scope,
.status = final_status,
});
}
}
const extra = if (root) |r| usage.asObject(r.get("extra_usage")) else null;
if (extra) |ex| {
const is_enabled = ex.get("is_enabled");
const enabled_bool = if (is_enabled) |b| switch (b) {
.bool => |v| v,
else => false,
} else false;
if (enabled_bool) {
const monthly_limit = usage.asNumber(ex.get("monthly_limit"));
const used_credits = usage.asNumber(ex.get("used_credits"));
const percent: ?f64 = if (monthly_limit == null or monthly_limit.? == 0.0 or used_credits == null)
null
else
(used_credits.? / monthly_limit.?) * 100.0;
const detail: ?[]const u8 = if (monthly_limit) |ml| blk: {
const uc_int = usage.toInt(i64, used_credits orelse 0.0) orelse break :blk null;
const ml_int = usage.toInt(i64, ml) orelse break :blk null;
break :blk try std.fmt.allocPrint(alloc, "{d} of {d} credits", .{ uc_int, ml_int });
} else null;
try limits.append(alloc, .{
.id = "extra_usage",
.label = "Extra usage",
.usedPercent = percent,
.resetsAt = null,
.status = usage.statusFor(percent),
.detail = detail,
});
} else {
const credits_ever_enabled = ex.get("credits_ever_enabled");
const ever_bool = if (credits_ever_enabled) |b| switch (b) {
.bool => |v| v,
else => false,
} else false;
if (ever_bool) {
try notes.append(alloc, "Extra usage is off");
}
}
}
return ParseUsageResult{
.limits = limits.items,
.notes = notes.items,
};
}
pub fn advance(
alloc: std.mem.Allocator,
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 "";
if (!std.mem.eql(u8, kind, "oauth")) {
return .{ .report = .{ .error_message = "anthropic needs an OAuth account" } };
}
const access = usage.asString(cred_root.get("access")) orelse "";
const cred_email = usage.stringOf(cred_root.get("email"));
const cred_account_id = usage.stringOf(cred_root.get("accountId"));
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" } };
};
if (resp_array.items.len == 0) {
var headers = std.ArrayList([2][]const u8).empty;
const auth_hdr = try std.fmt.allocPrint(alloc, "Bearer {s}", .{access});
try headers.append(alloc, .{ "Authorization", auth_hdr });
try headers.append(alloc, .{ "anthropic-beta", OAUTH_BETA });
try headers.append(alloc, .{ "User-Agent", "prime-agent" });
const requests = try alloc.alloc(usage.UsageRequest, 2);
requests[0] = .{
.method = "GET",
.url = ANTHROPIC_USAGE_URL,
.headers = headers.items,
};
requests[1] = .{
.method = "GET",
.url = ANTHROPIC_PROFILE_URL,
.headers = headers.items,
};
return .{ .requests = requests };
}
const first = switch (try usage.parseFirstResponse(alloc, resp_array, "usage body is not JSON")) {
.ok => |f| f,
.err => |msg| return .{ .report = .{ .error_message = msg } },
};
const body_parsed = first.parsed;
// Profile response (optional / responses[1])
var profile_obj: ?std.json.ObjectMap = null;
if (resp_array.items.len > 1) {
const resp1_obj = usage.asObject(resp_array.items[1]);
if (resp1_obj) |r1| {
if (usage.asString(r1.get("body"))) |pbody| {
if (std.json.parseFromSlice(std.json.Value, alloc, pbody, .{})) |p_parsed| {
profile_obj = usage.asObject(p_parsed.value);
} else |_| {}
}
}
}
const org_obj = if (profile_obj) |p| usage.asObject(p.get("organization")) else null;
const acct_obj = if (profile_obj) |p| usage.asObject(p.get("account")) else null;
const parsed_usage = try parseAnthropicUsage(alloc, body_parsed.value, now_ms);
const plan_type = if (org_obj) |o|
(usage.stringOf(o.get("seat_tier")) orelse usage.stringOf(o.get("rate_limit_tier")))
else
null;
const account_email = if (acct_obj) |a| usage.stringOf(a.get("email")) else null;
const final_email: ?[]const u8 = if (account_email) |em| blk: {
const lowered = try alloc.alloc(u8, em.len);
break :blk std.ascii.lowerString(lowered, em);
} else cred_email;
const final_account_id = if (cred_account_id) |cid|
cid
else if (acct_obj) |a|
usage.stringOf(a.get("uuid"))
else
null;
return .{
.report = .{
.planType = plan_type,
.email = final_email,
.accountId = final_account_id,
.limits = parsed_usage.limits,
.notes = parsed_usage.notes,
.error_message = null,
},
};
}