diff --git a/README.md b/README.md index 1e82882..666502c 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,22 @@ zig build test # core unit tests + the C ABI smoke test zig build smoke # C ABI smoke test alone zig build run -- providers zig build run -- advance ... [--now-ms N] +zig build run -- advance - # the same call as one JSON envelope on stdin zig build run -- claims ``` The `smoke` step is not decoration: it compiles `tests/c_smoke.c` with the toolchain, links the installed static library, exercises multi-round state, and asserts against recorded provider bodies. +`advance -` takes the whole call as one JSON envelope on stdin instead, so a credential never +lands in argv where other local processes can read it. It is as stateless as the argument form: +the host keeps every round's responses and replays them. + +```sh +echo '{"provider":"anthropic","credential":{"kind":"oauth","access":"…"},"responses":[],"nowMs":0}' \ + | zig build run -- advance - +``` + ```sh swift build --package-path swift swift run --package-path swift usage-smoke tests/fixtures @@ -72,6 +82,17 @@ For a macOS 13 host, build the library against its deployment target: zig build -Dtarget=aarch64-macos.13 ``` +## Limits + +A report's `limits[]` carries what a host needs to track a limit over time, beside the display +text: + +| field | meaning | +| --- | --- | +| `id` | Stable machine key within the provider (`weekly_opus`, `primary`, `codex_bengalfox.secondary`). `label` is display text and can change. | +| `windowSeconds` | Window length when the provider states it or the plan fixes it; absent when unknown or calendar-shaped. | +| `scope` | The provider's own name for the model family or feature the limit is restricted to; absent means it counts all usage on the account. | + ## Parity with the TypeScript reference `prime-agent` carries the reference implementation in `packages/ai/src/utils/usage/`, and diff --git a/include/usage.h b/include/usage.h index 99c96e7..f16979c 100644 --- a/include/usage.h +++ b/include/usage.h @@ -19,7 +19,7 @@ extern "C" { #endif -/* ABI version. Bumped to 3 for explicit host-owned cross-call state. */ +/* ABI version. Bumped to 4 for a limit's id, windowSeconds and scope. */ uint32_t usage_abi_version(void); /* Release a string returned by this library. NULL is accepted. */ diff --git a/src/main.zig b/src/main.zig index 26d39e2..6072746 100644 --- a/src/main.zig +++ b/src/main.zig @@ -8,6 +8,8 @@ const help_text = \\ providers supported providers info \\ advance ... advance feed state across rounds \\ [--now-ms N] + \\ advance - the same, from one JSON envelope on stdin: + \\ {"provider","credential","responses":[round...],"nowMs"?} \\ claims account id and email from an OAuth token \\ ; @@ -41,6 +43,59 @@ fn readFile(init: std.process.Init, path: []const u8, arena: std.mem.Allocator) return std.Io.Dir.cwd().readFileAlloc(init.io, path, arena, std.Io.Limit.limited(8 * 1024 * 1024)); } +/// Replays every round through one fresh state, printing one step per line: a host keeps the +/// responses, and the process keeps nothing. +fn advanceRounds( + init: std.process.Init, + arena: std.mem.Allocator, + provider: []const u8, + credential: []const u8, + rounds: []const []const u8, + now_ms: i64, +) !u8 { + var state = usage.State.init(arena); + defer state.deinit(); + + for (rounds) |responses| { + var w = usage.Writer.init(arena); + const outcome = usage.advance(arena, &state, provider, credential, responses, now_ms) catch |e| { + var err_w = usage.Writer.init(arena); + usage.writeStep(&err_w, .{ .err = @errorName(e) }) catch return 1; + try emit(init, err_w.items()); + return 0; + }; + try usage.writeStep(&w, outcome); + try emit(init, w.items()); + } + return 0; +} + +/// `advance -`: the whole call as one JSON envelope on stdin, so a credential never reaches argv, +/// where any local process can read it. +fn advanceEnvelope(init: std.process.Init, arena: std.mem.Allocator) !u8 { + var buffer: [8192]u8 = undefined; + var reader = std.Io.File.stdin().readerStreaming(init.io, &buffer); + const input = reader.interface.allocRemaining(arena, std.Io.Limit.limited(8 * 1024 * 1024)) catch |e| + return fail(init, @errorName(e)); + + const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, input, .{}) catch + return fail(init, "the envelope is not JSON"); + const root = usage.asObject(parsed) orelse return fail(init, "the envelope is not an object"); + const provider = usage.asString(root.get("provider")) orelse return fail(init, "the envelope needs a provider"); + const credential = root.get("credential") orelse return fail(init, "the envelope needs a credential"); + const responses = usage.asArray(root.get("responses")) orelse return fail(init, "the envelope needs responses"); + const now_ms = usage.toInt(i64, usage.asNumber(root.get("nowMs"))) orelse nowMs(); + + // An envelope asking for the first requests has no rounds yet; that is one empty round. + var rounds = std.ArrayList([]const u8).empty; + if (responses.items.len == 0) try rounds.append(arena, "[]"); + for (responses.items) |round| { + try rounds.append(arena, try std.json.Stringify.valueAlloc(arena, round, .{})); + } + const credential_text = try std.json.Stringify.valueAlloc(arena, credential, .{}); + return advanceRounds(init, arena, provider, credential_text, rounds.items, now_ms); +} + pub fn main(init: std.process.Init) !u8 { const arena = init.arena.allocator(); const argv = try std.process.Args.toSlice(init.minimal.args, arena); @@ -56,7 +111,8 @@ pub fn main(init: std.process.Init) !u8 { } if (std.mem.eql(u8, command, "advance")) { - if (argv.len < 5) return fail(init, "advance needs ..."); + if (argv.len == 3 and std.mem.eql(u8, argv[2], "-")) return advanceEnvelope(init, arena); + if (argv.len < 5) return fail(init, "advance needs ... or -"); const provider = argv[2]; const cred_arg = argv[3]; @@ -79,26 +135,15 @@ pub fn main(init: std.process.Init) !u8 { else readFile(init, cred_arg, arena) catch |e| return fail(init, @errorName(e)); - var state = usage.State.init(arena); - defer state.deinit(); - + var rounds = std.ArrayList([]const u8).empty; for (resp_args.items) |resp_arg| { const resp_str = if (std.mem.startsWith(u8, resp_arg, "[") or std.mem.startsWith(u8, resp_arg, "{")) resp_arg else readFile(init, resp_arg, arena) catch |e| return fail(init, @errorName(e)); - - var w = usage.Writer.init(arena); - const outcome = usage.advance(arena, &state, provider, cred_str, resp_str, now_ms) catch |e| { - var err_w = usage.Writer.init(arena); - usage.writeStep(&err_w, .{ .err = @errorName(e) }) catch return 1; - try emit(init, err_w.items()); - return 0; - }; - try usage.writeStep(&w, outcome); - try emit(init, w.items()); + try rounds.append(arena, resp_str); } - return 0; + return advanceRounds(init, arena, provider, cred_str, rounds.items, now_ms); } if (std.mem.eql(u8, command, "claims")) { diff --git a/src/providers/alibaba.zig b/src/providers/alibaba.zig index 0b34d39..093a343 100644 --- a/src/providers/alibaba.zig +++ b/src/providers/alibaba.zig @@ -122,9 +122,11 @@ pub fn parseAlibabaUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !Pa const reset = usage.asNumber(ud.get("per5HourResetTime")); const reset_ms = usage.toInt(i64, reset); try limits.append(alloc, .{ + .id = "five_hour", .label = "Session", .usedPercent = used_pct, .windowLabel = "5h window", + .windowSeconds = 5 * 3600, .resetsAt = reset_ms, .status = usage.statusFor(used_pct), }); @@ -136,9 +138,11 @@ pub fn parseAlibabaUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !Pa const reset = usage.asNumber(ud.get("per1WeekResetTime")); const reset_ms = usage.toInt(i64, reset); try limits.append(alloc, .{ + .id = "weekly", .label = "Weekly", .usedPercent = used_pct, .windowLabel = "7d window", + .windowSeconds = 7 * 86_400, .resetsAt = reset_ms, .status = usage.statusFor(used_pct), }); diff --git a/src/providers/anthropic.zig b/src/providers/anthropic.zig index d20887a..a41c5ec 100644 --- a/src/providers/anthropic.zig +++ b/src/providers/anthropic.zig @@ -12,8 +12,19 @@ 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; @@ -31,26 +42,30 @@ fn titleCase(alloc: std.mem.Allocator, value: []const u8) ![]const u8 { } pub fn windowForKind(alloc: std.mem.Allocator, kind: []const u8) !WindowInfo { - if (std.mem.eql(u8, kind, "session") or std.mem.endsWith(u8, kind, "_session")) { - return .{ .label = "Session", .windowLabel = "5h window" }; + 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" }; + 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" }; + 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" }; + 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" }; + return .{ .label = label, .windowLabel = "24h window", .windowSeconds = one_day, .scope = kindScope(scope) }; } const label = try titleCase(alloc, kind); return .{ .label = label, .windowLabel = null }; @@ -92,10 +107,14 @@ pub fn parseAnthropicUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, _ 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, }); } @@ -124,6 +143,7 @@ pub fn parseAnthropicUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, _ } else null; try limits.append(alloc, .{ + .id = "extra_usage", .label = "Extra usage", .usedPercent = percent, .resetsAt = null, diff --git a/src/providers/antigravity.zig b/src/providers/antigravity.zig index da62940..dece742 100644 --- a/src/providers/antigravity.zig +++ b/src/providers/antigravity.zig @@ -66,10 +66,10 @@ pub fn parseAntigravityUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, } var limits = std.ArrayList(usage.UsageLimit).empty; - const families = [_]struct { label: []const u8, info: *const FamilyInfo }{ - .{ .label = "Gemini", .info = &gemini }, - .{ .label = "Claude", .info = &claude }, - .{ .label = "GPT-OSS", .info = &gpt }, + const families = [_]struct { id: []const u8, label: []const u8, info: *const FamilyInfo }{ + .{ .id = "gemini", .label = "Gemini", .info = &gemini }, + .{ .id = "claude", .label = "Claude", .info = &claude }, + .{ .id = "gpt-oss", .label = "GPT-OSS", .info = &gpt }, }; for (families) |f| { @@ -91,11 +91,14 @@ pub fn parseAntigravityUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, const status = if (used_percent) |up| usage.statusFor(up) else "unknown"; + // Each family's buckets are its own quota, so the family is also the scope. try limits.append(alloc, .{ + .id = f.id, .label = f.label, .usedPercent = used_percent, .windowLabel = window_label, .resetsAt = resets_at, + .scope = f.label, .detail = detail, .status = status, }); diff --git a/src/providers/codex.zig b/src/providers/codex.zig index a268479..7d5f9d7 100644 --- a/src/providers/codex.zig +++ b/src/providers/codex.zig @@ -6,7 +6,7 @@ const usage = @import("../usage.zig"); pub const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; -fn parseWindow(alloc: std.mem.Allocator, window_val: ?std.json.Value, now_ms: i64) !?usage.UsageLimit { +fn parseWindow(alloc: std.mem.Allocator, id: []const u8, window_val: ?std.json.Value, now_ms: i64, scope: ?[]const u8) !?usage.UsageLimit { const value = usage.asObject(window_val) orelse return null; const used_percent = usage.asNumber(value.get("used_percent")); const reset_at = usage.asNumber(value.get("reset_at")); @@ -22,12 +22,16 @@ fn parseWindow(alloc: std.mem.Allocator, window_val: ?std.json.Value, now_ms: i6 null; const window_lbl = try usage.formatWindowLabel(alloc, limit_window); + const window_seconds: ?i64 = if (limit_window) |lw| (if (lw > 0) usage.roundToInt(i64, lw) else null) else null; return usage.UsageLimit{ + .id = id, .label = "Usage", .usedPercent = used_percent, .windowLabel = window_lbl, + .windowSeconds = window_seconds, .resetsAt = reset_ms, + .scope = scope, .status = usage.statusFor(used_percent), }; } @@ -43,14 +47,14 @@ pub fn parseCodexUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, now_m var limits = std.ArrayList(usage.UsageLimit).empty; const primary_val = if (rate_limit) |rl| rl.get("primary_window") else null; - if (try parseWindow(alloc, primary_val, now_ms)) |pw| { + if (try parseWindow(alloc, "primary", primary_val, now_ms, null)) |pw| { var w = pw; w.label = "Primary"; try limits.append(alloc, w); } const secondary_val = if (rate_limit) |rl| rl.get("secondary_window") else null; - if (try parseWindow(alloc, secondary_val, now_ms)) |sw| { + if (try parseWindow(alloc, "secondary", secondary_val, now_ms, null)) |sw| { var w = sw; w.label = "Secondary"; try limits.append(alloc, w); @@ -61,19 +65,22 @@ pub fn parseCodexUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, now_m for (arr.items) |entry| { const item = usage.asObject(entry) orelse continue; const extra = usage.asObject(item.get("rate_limit")); - const name = usage.asString(item.get("limit_name")) orelse - usage.asString(item.get("metered_feature")) orelse - "Additional"; + const limit_name = usage.asString(item.get("limit_name")); + const feature = usage.asString(item.get("metered_feature")); + const name = limit_name orelse feature orelse "Additional"; + // The metered feature is the machine key; the limit name is what it restricts. + const key = feature orelse limit_name orelse "additional"; + const scope = limit_name orelse feature; const extra_primary = if (extra) |e| e.get("primary_window") else null; - if (try parseWindow(alloc, extra_primary, now_ms)) |ep| { + if (try parseWindow(alloc, try std.fmt.allocPrint(alloc, "{s}.primary", .{key}), extra_primary, now_ms, scope)) |ep| { var w = ep; w.label = try std.fmt.allocPrint(alloc, "{s} · Primary", .{name}); try limits.append(alloc, w); } const extra_secondary = if (extra) |e| e.get("secondary_window") else null; - if (try parseWindow(alloc, extra_secondary, now_ms)) |es| { + if (try parseWindow(alloc, try std.fmt.allocPrint(alloc, "{s}.secondary", .{key}), extra_secondary, now_ms, scope)) |es| { var w = es; w.label = try std.fmt.allocPrint(alloc, "{s} · Secondary", .{name}); try limits.append(alloc, w); diff --git a/src/providers/hyper.zig b/src/providers/hyper.zig index aa61a77..ccec2f5 100644 --- a/src/providers/hyper.zig +++ b/src/providers/hyper.zig @@ -55,10 +55,13 @@ pub fn parseHyperBilling( 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}); @@ -91,9 +94,11 @@ pub fn parseHyperBilling( 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, diff --git a/src/providers/opencode_go.zig b/src/providers/opencode_go.zig index 8606744..0781ee8 100644 --- a/src/providers/opencode_go.zig +++ b/src/providers/opencode_go.zig @@ -11,12 +11,13 @@ const WindowDef = struct { key: []const u8, label: []const u8, windowLabel: []const u8, + windowSeconds: i64, }; const OPENCODE_WINDOWS = [_]WindowDef{ - .{ .key = "rolling", .label = "Session", .windowLabel = "5h window" }, - .{ .key = "weekly", .label = "Weekly", .windowLabel = "7d window" }, - .{ .key = "monthly", .label = "Monthly", .windowLabel = "30d window" }, + .{ .key = "rolling", .label = "Session", .windowLabel = "5h window", .windowSeconds = 5 * 3600 }, + .{ .key = "weekly", .label = "Weekly", .windowLabel = "7d window", .windowSeconds = 7 * 86_400 }, + .{ .key = "monthly", .label = "Monthly", .windowLabel = "30d window", .windowSeconds = 30 * 86_400 }, }; pub fn parseOpenCodeGoUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) ![]const usage.UsageLimit { @@ -47,9 +48,11 @@ pub fn parseOpenCodeGoUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) usage.statusFor(used_percent); try limits.append(alloc, .{ + .id = win.key, .label = win.label, .usedPercent = used_percent, .windowLabel = win.windowLabel, + .windowSeconds = win.windowSeconds, .resetsAt = resets_at, .status = status, }); diff --git a/src/providers/zai.zig b/src/providers/zai.zig index 9d27ba1..81d49e0 100644 --- a/src/providers/zai.zig +++ b/src/providers/zai.zig @@ -44,6 +44,18 @@ pub fn parseZaiUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !ParseZ var window_label: ?[]const u8 = null; var detail: ?[]const u8 = null; + // One type can repeat with a different period, so the period is part of the key. + const number = usage.asNumber(item.get("number")); + const unit_int = usage.toInt(i64, unit); + const number_int = usage.toInt(i64, number); + const base_id = if (type_str.len > 0) try std.ascii.allocLowerString(alloc, type_str) else "limit"; + const id = if (unit_int != null and number_int != null) + try std.fmt.allocPrint(alloc, "{s}:{d}:{d}", .{ base_id, unit_int.?, number_int.? }) + else + base_id; + // Unit 3 counts hours; the other units are not pinned down, so they carry no length. + const window_seconds: ?i64 = if (unit_int == 3 and number_int != null) number_int.? * 3600 else null; + if (std.mem.eql(u8, type_str, "TIME_LIMIT") or (unit != null and unit.? == 5.0)) { label = "Requests"; if (cur != null and total != null) { @@ -67,9 +79,11 @@ pub fn parseZaiUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !ParseZ usage.statusFor(used_percent); try limits.append(alloc, .{ + .id = id, .label = label, .usedPercent = used_percent, .windowLabel = window_label, + .windowSeconds = window_seconds, .resetsAt = resets_at, .detail = detail, .status = status, diff --git a/src/usage.zig b/src/usage.zig index e66ac83..cebf1bc 100644 --- a/src/usage.zig +++ b/src/usage.zig @@ -1,12 +1,12 @@ //! Pure provider usage query core. //! //! Driven by the host: no sockets, no clock, no filesystem. -//! Implements ABI 3 (explicit host-owned cross-call state). +//! Implements ABI 4 (limit id, windowSeconds and scope). //! The core owns no cross-call state; the host owns it and passes it back. const std = @import("std"); pub const iso8601 = @import("iso8601.zig"); -pub const abi_version: u32 = 3; +pub const abi_version: u32 = 4; pub const ProviderInfo = struct { provider: []const u8, @@ -84,10 +84,18 @@ pub const State = struct { }; pub const UsageLimit = struct { + /// Stable machine key for this limit within its provider; `label` is display text. + id: []const u8, label: []const u8, usedPercent: ?f64 = null, windowLabel: ?[]const u8 = null, + /// Window length when the provider states it or the plan fixes it; null when unknown or + /// calendar-shaped (a month). + windowSeconds: ?i64 = null, resetsAt: ?i64 = null, + /// The provider's own name for the model family or feature this limit is restricted to; + /// null means it counts all usage on the account. + scope: ?[]const u8 = null, status: []const u8, detail: ?[]const u8 = null, }; @@ -457,7 +465,9 @@ pub fn writeStep(w: *Writer, outcome: Outcome) !void { try w.raw("\"limits\":["); for (rep.limits, 0..) |lim, i| { if (i > 0) try w.byte(','); - try w.raw("{\"label\":"); + try w.raw("{\"id\":"); + try w.string(lim.id); + try w.raw(",\"label\":"); try w.string(lim.label); if (lim.usedPercent) |up| { if (std.math.isFinite(up)) { @@ -469,9 +479,16 @@ pub fn writeStep(w: *Writer, outcome: Outcome) !void { try w.raw(",\"windowLabel\":"); try w.string(wl); } + if (lim.windowSeconds) |ws| { + try w.print(",\"windowSeconds\":{d}", .{ws}); + } if (lim.resetsAt) |ra| { try w.print(",\"resetsAt\":{d}", .{ra}); } + if (lim.scope) |sc| { + try w.raw(",\"scope\":"); + try w.string(sc); + } if (lim.detail) |dt| { try w.raw(",\"detail\":"); try w.string(dt); diff --git a/swift/Sources/UsageCore/UsageCore.swift b/swift/Sources/UsageCore/UsageCore.swift index a648d34..d0f6e16 100644 --- a/swift/Sources/UsageCore/UsageCore.swift +++ b/swift/Sources/UsageCore/UsageCore.swift @@ -3,10 +3,15 @@ import Foundation /// One quota window, matching the contract in packages/ai/docs/usage-feeds.md. public struct UsageLimit: Codable, Sendable, Equatable { + /// Stable machine key within the provider; `label` is display text. + public var id: String public var label: String public var usedPercent: Double? public var windowLabel: String? + public var windowSeconds: Int64? public var resetsAt: Int64? + /// The model family or feature this limit is restricted to; nil counts all usage. + public var scope: String? public var status: String public var detail: String? } @@ -154,7 +159,7 @@ public enum UsageCoreError: Error, CustomStringConvertible { } public enum UsageCore { - public static let expectedAbiVersion: UInt32 = 3 + public static let expectedAbiVersion: UInt32 = 4 public static var abiVersion: UInt32 { usage_abi_version() } public static func checkAbiVersion() throws { diff --git a/swift/Sources/UsageSmoke/main.swift b/swift/Sources/UsageSmoke/main.swift index 934072f..b2b27c1 100644 --- a/swift/Sources/UsageSmoke/main.swift +++ b/swift/Sources/UsageSmoke/main.swift @@ -35,7 +35,7 @@ func check(_ condition: Bool, _ message: String) { } do { - check(UsageCore.abiVersion == 3, "unexpected ABI version \(UsageCore.abiVersion)") + check(UsageCore.abiVersion == 4, "unexpected ABI version \(UsageCore.abiVersion)") let providers = try UsageCore.providers() check(providers.contains { $0.provider == "openai-codex" }, "codex should be a known provider") diff --git a/tests/c_smoke.c b/tests/c_smoke.c index babc04c..5bd7d40 100644 --- a/tests/c_smoke.c +++ b/tests/c_smoke.c @@ -60,7 +60,7 @@ static char *wrap_response_json(const char *body) { int main(int argc, char **argv) { if (argc < 2) return 2; - check(usage_abi_version() == 3, "ABI version must be 3"); + check(usage_abi_version() == 4, "ABI version must be 4"); char *providers = usage_providers(); check(providers != NULL, "providers must not be NULL"); diff --git a/tests/usage_test.zig b/tests/usage_test.zig index 1599cc9..3553d80 100644 --- a/tests/usage_test.zig +++ b/tests/usage_test.zig @@ -82,8 +82,8 @@ test "codex second step report with windows and additional limits" { const text = try advance(a, "openai-codex", cred, body, now); try std.testing.expect(std.mem.indexOf(u8, text, "\"planType\":\"prolite\"") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Primary\",\"usedPercent\":100,\"windowLabel\":\"7d window\",\"status\":\"exhausted\"") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Spark · Primary\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"primary\",\"label\":\"Primary\",\"usedPercent\":100,\"windowLabel\":\"7d window\",\"windowSeconds\":604800,\"status\":\"exhausted\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"Spark.primary\",\"label\":\"Spark · Primary\",\"usedPercent\":0,\"windowLabel\":\"5h window\",\"windowSeconds\":18000,\"scope\":\"Spark\"") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"resetsAt\":1700003600000") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"email\":\"user@example.com\"") != null); } @@ -114,8 +114,8 @@ test "anthropic second step report" { try std.testing.expect(std.mem.indexOf(u8, text, "\"email\":\"user@example.com\"") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"accountId\":\"uuid-123\"") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"planType\":\"team_standard\"") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Session\",\"usedPercent\":0,\"windowLabel\":\"5h window\",\"status\":\"ok\"") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Weekly\",\"usedPercent\":94,\"windowLabel\":\"7d window\",\"resetsAt\":1789545600037,\"status\":\"warning\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"session\",\"label\":\"Session\",\"usedPercent\":0,\"windowLabel\":\"5h window\",\"windowSeconds\":18000,\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"weekly_all\",\"label\":\"Weekly\",\"usedPercent\":94,\"windowLabel\":\"7d window\",\"windowSeconds\":604800,\"resetsAt\":1789545600037,\"status\":\"warning\"") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"notes\":[\"Extra usage is off\"]") != null); } @@ -129,10 +129,52 @@ test "anthropic extra usage enabled" { \\[{"status":200,"body":"{\"limits\":[],\"extra_usage\":{\"is_enabled\":true,\"monthly_limit\":100,\"used_credits\":25}}"},{"status":200,"body":"{}"}] ; const text = try advance(a, "anthropic", cred, body, 0); - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Extra usage\",\"usedPercent\":25") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"extra_usage\",\"label\":\"Extra usage\",\"usedPercent\":25") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"detail\":\"25 of 100 credits\"") != null); } +test "anthropic scoped windows carry their scope, and the body's scope wins" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"oauth\",\"access\":\"ant-token\"}"; + const body = + \\[{"status":200,"body":"{\"limits\":[{\"kind\":\"weekly_opus\",\"percent\":40},{\"kind\":\"daily_all\",\"percent\":5},{\"kind\":\"weekly_sonnet\",\"percent\":1,\"scope\":\"claude-sonnet\"}]}"},{"status":200,"body":"{}"}] + ; + const text = try advance(a, "anthropic", cred, body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"weekly_opus\",\"label\":\"Weekly (Opus)\",\"usedPercent\":40,\"windowLabel\":\"7d window\",\"windowSeconds\":604800,\"scope\":\"opus\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"daily_all\",\"label\":\"Daily (All)\",\"usedPercent\":5,\"windowLabel\":\"24h window\",\"windowSeconds\":86400,\"status\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"weekly_sonnet\",\"label\":\"Weekly (Sonnet)\",\"usedPercent\":1,\"windowLabel\":\"7d window\",\"windowSeconds\":604800,\"scope\":\"claude-sonnet\"") != null); +} + +test "codex additional limits are keyed by their metered feature" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"oauth\",\"access\":\"test-token\"}"; + const body = + \\[{"status":200,"body":"{\"additional_rate_limits\":[{\"limit_name\":\"GPT-5.3-Codex-Spark\",\"metered_feature\":\"codex_bengalfox\",\"rate_limit\":{\"secondary_window\":{\"used_percent\":3,\"limit_window_seconds\":604800}}}]}"}] + ; + const text = try advance(a, "openai-codex", cred, body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"codex_bengalfox.secondary\",\"label\":\"GPT-5.3-Codex-Spark · Secondary\",\"usedPercent\":3,\"windowLabel\":\"7d window\",\"windowSeconds\":604800,\"scope\":\"GPT-5.3-Codex-Spark\"") != null); +} + +test "zai keys a limit by type and period, and counts unit 3 in hours" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"key\"}"; + const body = + \\[{"status":200,"body":"{\"code\":200,\"success\":true,\"data\":{\"limits\":[{\"type\":\"TOKENS_LIMIT\",\"unit\":3,\"number\":5,\"percentage\":12},{\"type\":\"TIME_LIMIT\",\"unit\":5,\"number\":1,\"usage\":100,\"currentValue\":0,\"percentage\":0}]}}"}] + ; + const text = try advance(a, "zai", cred, body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"tokens_limit:3:5\",\"label\":\"Tokens\",\"usedPercent\":12,\"windowLabel\":\"5h window\",\"windowSeconds\":18000,") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"time_limit:5:1\",\"label\":\"Requests\",\"usedPercent\":0,\"detail\"") != null); +} + test "access token claims give account id and a normalized email" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -163,7 +205,8 @@ test "antigravity first step and report conversion" { ; const now: i64 = 1_789_514_000_000; const rep_text = try advance(a, "google-antigravity", cred, body, now); - try std.testing.expect(std.mem.indexOf(u8, rep_text, "\"label\":\"Gemini\"") != null); + try std.testing.expect(std.mem.indexOf(u8, rep_text, "\"id\":\"gemini\",\"label\":\"Gemini\"") != null); + try std.testing.expect(std.mem.indexOf(u8, rep_text, "\"scope\":\"Gemini\"") != null); try std.testing.expect(std.mem.indexOf(u8, rep_text, "\"usedPercent\":83.4") != null); try std.testing.expect(std.mem.indexOf(u8, rep_text, "\"detail\":\"17% free\"") != null); try std.testing.expect(std.mem.indexOf(u8, rep_text, "\"windowLabel\":\"1h window\"") != null); @@ -215,7 +258,8 @@ test "hyper two-step flow and unit conversion" { try std.testing.expect(std.mem.indexOf(u8, step2, "\"email\":\"user@example.com\"") != null); try std.testing.expect(std.mem.indexOf(u8, step2, "\"usedPercent\":5.2") != null); try std.testing.expect(std.mem.indexOf(u8, step2, "\"detail\":\"237 of 250 credits\"") != null); - try std.testing.expect(std.mem.indexOf(u8, step2, "\"windowLabel\":\"24h window\"") != null); + try std.testing.expect(std.mem.indexOf(u8, step2, "\"id\":\"hypercredits\"") != null); + try std.testing.expect(std.mem.indexOf(u8, step2, "\"windowLabel\":\"24h window\",\"windowSeconds\":86400") != null); try std.testing.expect(std.mem.indexOf(u8, step2, "\"balance\":\"11.83\"") != null); try std.testing.expect(std.mem.indexOf(u8, step2, "\"notes\":[\"Team: My Team\"]") != null); } @@ -231,9 +275,9 @@ test "opencode-go status mapping and windows" { ; const text = try advance(a, "opencode-go", cred, body, 0); try std.testing.expect(std.mem.indexOf(u8, text, "\"planType\":\"Go\"") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Session\",\"usedPercent\":10,\"windowLabel\":\"5h window\",\"resetsAt\":1789533614000,\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"rolling\",\"label\":\"Session\",\"usedPercent\":10,\"windowLabel\":\"5h window\",\"windowSeconds\":18000,\"resetsAt\":1789533614000,\"status\":\"ok\"") != null); // rate-limited status -> exhausted even though percent is 50 - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Monthly\",\"usedPercent\":50,\"windowLabel\":\"30d window\",\"resetsAt\":1790665316000,\"status\":\"exhausted\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"monthly\",\"label\":\"Monthly\",\"usedPercent\":50,\"windowLabel\":\"30d window\",\"windowSeconds\":2592000,\"resetsAt\":1790665316000,\"status\":\"exhausted\"") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"notes\":[\"OpenCode Go unified models\"]") != null); } @@ -255,9 +299,9 @@ test "zai authorization without Bearer, epoch seconds/ms, and ratio fallback" { ; const text = try advance(a, "zai", cred, body, 0); try std.testing.expect(std.mem.indexOf(u8, text, "\"planType\":\"Lite\"") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Requests\",\"usedPercent\":10,\"resetsAt\":1792027143998,\"detail\":\"10 / 100 reqs\",\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"time_limit\",\"label\":\"Requests\",\"usedPercent\":10,\"resetsAt\":1792027143998,\"detail\":\"10 / 100 reqs\",\"status\":\"ok\"") != null); // 1792027143 (< 1e11) converted from epoch seconds to 1792027143000 ms, ratio 25% - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Tokens\",\"usedPercent\":25,\"windowLabel\":\"5h window\",\"resetsAt\":1792027143000,\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"tokens_limit\",\"label\":\"Tokens\",\"usedPercent\":25,\"windowLabel\":\"5h window\",\"resetsAt\":1792027143000,\"status\":\"ok\"") != null); } test "zai error return when code != 200" { @@ -299,7 +343,7 @@ test "alibaba CLI answer parse: fraction to percent and epoch ms kept" { \\[{"status":0,"body":"{\"per5HourPercentage\":0.5,\"per1WeekPercentage\":0.83603712139856,\"per1WeekResetTime\":1790021160000}"}] ; const text = try advance(a, "alibaba", cred, cli_body, 1_700_000_000_000); - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Session\",\"usedPercent\":50,\"windowLabel\":\"5h window\",\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"id\":\"five_hour\",\"label\":\"Session\",\"usedPercent\":50,\"windowLabel\":\"5h window\",\"windowSeconds\":18000,\"status\":\"ok\"") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Weekly\"") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"resetsAt\":1790021160000") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"windowLabel\":\"7d window\"") != null);