From cf47adbb8c8df59a09ee16eaee83864030f9a088 Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Wed, 16 Sep 2026 04:25:16 +0300 Subject: [PATCH] fix(core): bump to ABI 3 with host-owned state, safe float casts, and pipe draining --- README.md | 14 +- build.zig | 12 +- include/usage.h | 16 +- src/c_api.zig | 24 ++- src/main.zig | 44 +++-- src/providers/alibaba.zig | 58 +++--- src/providers/anthropic.zig | 54 ++---- src/providers/antigravity.zig | 32 +--- src/providers/codex.zig | 34 ++-- src/providers/deepseek.zig | 25 +-- src/providers/hyper.zig | 151 +++++++--------- src/providers/opencode_go.zig | 25 +-- src/providers/zai.zig | 40 ++--- src/usage.zig | 204 ++++++++++++++++------ swift/Sources/UsageCore/UsageCore.swift | 30 ++++ swift/Sources/UsageCore/UsageDriver.swift | 49 +++++- swift/Sources/UsageSmoke/main.swift | 32 +++- tests/c_smoke.c | 121 ++++++++++--- tests/usage_test.zig | 121 ++++++++++++- 19 files changed, 689 insertions(+), 397 deletions(-) diff --git a/README.md b/README.md index 0be529e..1e82882 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,11 @@ takes another round. | function | purpose | | --- | --- | | `usage_abi_version()` | ABI version, bumped on any signature or shape change. | -| `usage_supported_providers()` | JSON array of provider ids, names and credential kinds. | +| `usage_providers()` | JSON array of provider ids, names and credential kinds. | +| `usage_state_new()` | Allocate opaque cross-round state for multi-round feeds (e.g. Hyper). | +| `usage_state_free(state)` | Free opaque cross-round state. NULL is accepted. | | `usage_credentials_from_access_token(jwt, len)` | `{"accountId"?, "email"?}` from OAuth token claims. | -| `usage_advance(provider, len, credential_json, credential_len, responses_json, responses_len, now_ms)` | Requests to answer, or the finished report. | +| `usage_advance(state, provider, len, credential_json, credential_len, responses_json, responses_len, now_ms)` | Requests to answer, or the finished report. | | `usage_free(ptr)` | Release a returned string. NULL is accepted. | Every call returns a NUL-terminated JSON string the caller frees with `usage_free`, and @@ -52,12 +54,12 @@ zig build # libusage.a, libusage.dylib, usage.h, usage CLI 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 ... [--now-ms N] zig build run -- claims ``` The `smoke` step is not decoration: it compiles `tests/c_smoke.c` with the toolchain, links -the installed static library, and calls the ABI the way a native host does. +the installed static library, exercises multi-round state, and asserts against recorded provider bodies. ```sh swift build --package-path swift @@ -106,9 +108,7 @@ Headers cross the ABI as ordered pairs because HTTP allows a repeated name; ## Status - Eight providers have feeds. Unknown providers return - `{"error":"unknown provider"}` and known providers with no feed return - `{"error":"provider has no usage feed"}`, which is how a host names the gap instead of - implying a provider has no limits. + `{"error":"unknown provider"}`. - Providers differ in what they can answer from. Alibaba's token-plan keys are inference-only, so its feed prefers the official `bl` CLI and falls back to a captured console session; a provider whose answer needs a second round trips the host loop again. diff --git a/build.zig b/build.zig index 4c8ed1a..b24d15e 100644 --- a/build.zig +++ b/build.zig @@ -36,7 +36,15 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - b.installArtifact(static_lib); + const install_static = b.addInstallArtifact(static_lib, .{}); + b.getInstallStep().dependOn(&install_static.step); + + if (target.result.os.tag == .macos) { + const ranlib_cmd = b.addSystemCommand(&[_][]const u8{"ranlib"}); + ranlib_cmd.addFileArg(b.getInstallStep().owner.path("zig-out/lib/libusage.a")); + ranlib_cmd.step.dependOn(&install_static.step); + b.getInstallStep().dependOn(&ranlib_cmd.step); + } const exe = b.addExecutable(.{ .name = "usage", @@ -63,7 +71,7 @@ pub fn build(b: *std.Build) void { // The ABI is the part native hosts consume, so it is exercised the way they call it: // a C program compiled with this toolchain, linked against the installed static - // library, reading a recorded provider body. + // library, exercising multi-round state and recorded provider bodies. const smoke_module = b.createModule(.{ .target = target, .optimize = optimize, diff --git a/include/usage.h b/include/usage.h index 61591cc..fc4ff87 100644 --- a/include/usage.h +++ b/include/usage.h @@ -19,7 +19,7 @@ extern "C" { #endif -/* ABI version. Bumped to 2 for host-driven requests/report protocol. */ +/* ABI version. Bumped to 3 for explicit host-owned cross-call state. */ uint32_t usage_abi_version(void); /* Release a string returned by this library. NULL is accepted. */ @@ -28,15 +28,25 @@ void usage_free(char *ptr); /* Provider definitions: [{"provider","providerName","credentialKinds"}] */ char *usage_providers(void); +/* Opaque state owned by the host across multi-round feeds (e.g. Hyper). + * The core owns no cross-call state; the host owns it and passes it back. + * Pass NULL for single-round or stateless feeds. + */ +typedef struct usage_state usage_state; + +usage_state *usage_state_new(void); +void usage_state_free(usage_state *state); + /* Advance feed state. * Returns either: * {"requests":[{"kind":"http","method","url","headers":[["k","v"]],"body"?}]} * or: * {"requests":[{"kind":"command","command","args":[],"stdin"?,"timeoutMs"?}]} * or: - * {"report":{providerName, accountId?, email?, planType?, limits[], balance?, notes?[], error?}} + * {"report":{accountId?, email?, planType?, limits[], balance?, notes?[], error?}} */ -char *usage_advance(const char *provider, size_t provider_len, +char *usage_advance(usage_state *state, + const char *provider, size_t provider_len, const char *credential_json, size_t credential_len, const char *responses_json, size_t responses_len, int64_t now_ms); diff --git a/src/c_api.zig b/src/c_api.zig index c2cc4d6..cf7beae 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -3,11 +3,18 @@ //! One rule for every export: return a JSON string the caller frees with `usage_free`, //! and report failure *inside* that JSON. A host never has to tell "no error" apart from //! "no data", and NULL means only that allocation failed. +//! +//! The core owns no cross-call state; the host owns it via usage_state and passes it back. const std = @import("std"); const usage = @import("usage.zig"); const alloc = std.heap.c_allocator; +/// Opaque cross-round state struct owned by the host. +pub const usage_state = struct { + inner: usage.State, +}; + /// C callers (Swift included) pass NULL for an absent string; treat it as empty rather /// than dereferencing a null pointer. fn slice(ptr: ?[*]const u8, len: usize) []const u8 { @@ -42,6 +49,19 @@ export fn usage_free(ptr: ?[*:0]u8) void { if (ptr) |p| alloc.free(std.mem.span(p)); } +export fn usage_state_new() ?*usage_state { + const s = alloc.create(usage_state) catch return null; + s.inner = usage.State.init(alloc); + return s; +} + +export fn usage_state_free(state: ?*usage_state) void { + if (state) |s| { + s.inner.deinit(); + alloc.destroy(s); + } +} + export fn usage_providers() ?[*:0]u8 { var arena = std.heap.ArenaAllocator.init(alloc); defer arena.deinit(); @@ -53,6 +73,7 @@ export fn usage_providers() ?[*:0]u8 { } export fn usage_advance( + state: ?*usage_state, provider: ?[*]const u8, provider_len: usize, credential_json: ?[*]const u8, @@ -68,8 +89,9 @@ export fn usage_advance( const prov_slice = slice(provider, provider_len); const cred_slice = slice(credential_json, credential_len); const resp_slice = slice(responses_json, responses_len); + const state_ptr: ?*usage.State = if (state) |s| &s.inner else null; - const outcome = usage.advance(a, prov_slice, cred_slice, resp_slice, now_ms) catch |e| { + const outcome = usage.advance(a, state_ptr, prov_slice, cred_slice, resp_slice, now_ms) catch |e| { return errorResult(a, message(e)); }; diff --git a/src/main.zig b/src/main.zig index f0435bd..26d39e2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,9 +5,10 @@ const usage = @import("usage.zig"); const help_text = \\usage - \\ providers supported providers info - \\ advance [--now-ms N] advance feed state - \\ claims account id and email from an OAuth token + \\ providers supported providers info + \\ advance ... advance feed state across rounds + \\ [--now-ms N] + \\ claims account id and email from an OAuth token \\ ; @@ -55,35 +56,48 @@ 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 < 5) return fail(init, "advance needs ..."); const provider = argv[2]; const cred_arg = argv[3]; - const resp_arg = argv[4]; + var resp_args = std.ArrayList([]const u8).empty; var now_ms = nowMs(); - var i: usize = 5; + var i: usize = 4; while (i < argv.len) : (i += 1) { if (std.mem.eql(u8, argv[i], "--now-ms") and i + 1 < argv.len) { now_ms = std.fmt.parseInt(i64, argv[i + 1], 10) catch now_ms; i += 1; + } else { + try resp_args.append(arena, argv[i]); } } - // Arguments could be raw JSON or file paths + if (resp_args.items.len == 0) return fail(init, "advance needs at least one responses argument"); + const cred_str = if (std.mem.startsWith(u8, cred_arg, "{")) cred_arg else readFile(init, cred_arg, arena) catch |e| return fail(init, @errorName(e)); - 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 state = usage.State.init(arena); + defer state.deinit(); - var w = usage.Writer.init(arena); - const outcome = try usage.advance(arena, provider, cred_str, resp_str, now_ms); - try usage.writeStep(&w, outcome); - try emit(init, w.items()); + 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()); + } return 0; } diff --git a/src/providers/alibaba.zig b/src/providers/alibaba.zig index 6a11c52..c743d30 100644 --- a/src/providers/alibaba.zig +++ b/src/providers/alibaba.zig @@ -1,6 +1,6 @@ //! Alibaba Model Studio usage feed implementation. //! -//! Mirrors packages/ai/src/utils/usage/providers/alibaba.ts field for field. +//! Mirrors prime-agent:packages/ai/src/utils/usage/providers/alibaba.ts field for field. const std = @import("std"); const usage = @import("../usage.zig"); @@ -85,12 +85,7 @@ pub const ALIBABA_SUBSCRIPTION_API = "zeldaHttp.apikeyMgr./tokenplan/personal/ap pub const ALIBABA_QUOTA_CONFIG_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config"; pub const ALIBABA_RESET_CARD_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/reset-card/list"; -fn stringOf(value: ?std.json.Value) ?[]const u8 { - const s = usage.asString(value) orelse return null; - const trimmed = std.mem.trim(u8, s, " \t\r\n"); - if (trimmed.len == 0) return null; - return trimmed; -} + /// Unwrap the console's envelope (`data.DataV2.data.data`) when present. The CLI answers with the /// inner payload directly, so one parser covers both. @@ -125,7 +120,7 @@ pub fn parseAlibabaUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !Pa if (usage.asNumber(ud.get("per5HourPercentage"))) |sf| { const used_pct = sf * 100.0; const reset = usage.asNumber(ud.get("per5HourResetTime")); - const reset_ms: ?i64 = if (reset) |r| @as(i64, @intFromFloat(r)) else null; + const reset_ms = usage.toInt(i64, reset); try limits.append(alloc, .{ .label = "Session", .usedPercent = used_pct, @@ -139,7 +134,7 @@ pub fn parseAlibabaUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !Pa if (usage.asNumber(ud.get("per1WeekPercentage"))) |wf| { const used_pct = wf * 100.0; const reset = usage.asNumber(ud.get("per1WeekResetTime")); - const reset_ms: ?i64 = if (reset) |r| @as(i64, @intFromFloat(r)) else null; + const reset_ms = usage.toInt(i64, reset); try limits.append(alloc, .{ .label = "Weekly", .usedPercent = used_pct, @@ -180,14 +175,14 @@ pub fn parseAlibabaQuotaConfig( try note_buf.appendSlice(alloc, ": "); var parts_wrote = false; - if (weekly) |w| { - const w_str = try std.fmt.allocPrint(alloc, "weekly {d}", .{@as(i64, @intFromFloat(w))}); + if (usage.toInt(i64, weekly)) |w| { + const w_str = try std.fmt.allocPrint(alloc, "weekly {d}", .{w}); try note_buf.appendSlice(alloc, w_str); parts_wrote = true; } - if (five_hour) |fh| { + if (usage.toInt(i64, five_hour)) |fh| { if (parts_wrote) try note_buf.appendSlice(alloc, ", "); - const fh_str = try std.fmt.allocPrint(alloc, "5h {d}", .{@as(i64, @intFromFloat(fh))}); + const fh_str = try std.fmt.allocPrint(alloc, "5h {d}", .{fh}); try note_buf.appendSlice(alloc, fh_str); } try note_buf.appendSlice(alloc, " credits"); @@ -206,9 +201,9 @@ pub const ConsoleSession = struct { pub fn parseConsoleSession(session_val: ?std.json.Value, default_host: []const u8) ?ConsoleSession { const session = usage.asObject(session_val) orelse return null; - const region = stringOf(session.get("region")) orelse return null; - const host = stringOf(session.get("url")) orelse default_host; - const secToken = stringOf(session.get("secToken")); + const region = usage.stringOf(session.get("region")) orelse return null; + const host = usage.stringOf(session.get("url")) orelse default_host; + const secToken = usage.stringOf(session.get("secToken")); const headers = usage.asObject(session.get("headers")); return .{ .host = host, @@ -224,7 +219,7 @@ pub fn regionFor(cred_root: ?std.json.ObjectMap, session: ?ConsoleSession) Aliba if (std.mem.eql(u8, kind, "cli")) { const config = usage.asObject(root.get("config")); if (config) |c| { - if (stringOf(c.get("region"))) |r_str| { + if (usage.stringOf(c.get("region"))) |r_str| { if (AlibabaRegion.fromString(r_str)) |r| return r; } } @@ -349,7 +344,7 @@ pub fn reportFromConsole( cred_root: ?std.json.ObjectMap, resp_array: std.json.Array, ) !usage.Outcome { - const cred_email = if (cred_root) |cr| stringOf(cr.get("email")) else null; + const cred_email = if (cred_root) |cr| usage.stringOf(cr.get("email")) else null; if (resp_array.items.len == 0) { return .{ .report = .{ .error_message = "console gateway HTTP no response" } }; @@ -388,10 +383,11 @@ pub fn reportFromConsole( if (quota_resp == null and resp_array.items.len > 2) quota_resp = usage.asObject(resp_array.items[2]); if (card_resp == null and resp_array.items.len > 3) card_resp = usage.asObject(resp_array.items[3]); - const status = if (usage_resp) |ro| usage.asNumber(ro.get("status")) else null; - if (status == null or status.? != 200.0) { + const status_num = if (usage_resp) |ro| usage.asNumber(ro.get("status")) else null; + const status = usage.toInt(i64, status_num); + if (status == null or status.? != 200) { const err_text = if (status) |st| - try std.fmt.allocPrint(alloc, "console gateway HTTP {d}", .{@as(i64, @intFromFloat(st))}) + try std.fmt.allocPrint(alloc, "console gateway HTTP {d}", .{st}) else "console gateway HTTP no response"; return .{ .report = .{ .error_message = err_text } }; @@ -419,8 +415,8 @@ pub fn reportFromConsole( const sub_unwrapped = unwrapEnvelope(sub_val); const sub_obj = usage.asObject(sub_unwrapped); - const plan_type = if (sub_obj) |so| stringOf(so.get("specCode")) else null; - const instance_code = if (sub_obj) |so| stringOf(so.get("instanceCode")) else null; + const plan_type = if (sub_obj) |so| usage.stringOf(so.get("specCode")) else null; + const instance_code = if (sub_obj) |so| usage.stringOf(so.get("instanceCode")) else null; const remaining_days = if (sub_obj) |so| usage.asNumber(so.get("remainingDays")) else null; // Quota config response @@ -438,8 +434,8 @@ pub fn reportFromConsole( for (parsed.notes) |n| try notes.append(alloc, n); for (quota_notes) |n| try notes.append(alloc, n); - if (remaining_days) |rd| { - const note = try std.fmt.allocPrint(alloc, "{d} days remaining in the plan", .{@as(i64, @intFromFloat(rd))}); + if (usage.toInt(i64, remaining_days)) |rd| { + const note = try std.fmt.allocPrint(alloc, "{d} days remaining in the plan", .{rd}); try notes.append(alloc, note); } @@ -478,11 +474,10 @@ pub fn reportFromCli( resp0: std.json.ObjectMap, region: AlibabaRegion, ) !CliReportOutcome { - const cred_account_id = if (cred_root) |cr| stringOf(cr.get("accountId")) else null; - const cred_email = if (cred_root) |cr| stringOf(cr.get("email")) else null; + const cred_account_id = if (cred_root) |cr| usage.stringOf(cr.get("accountId")) else null; + const cred_email = if (cred_root) |cr| usage.stringOf(cr.get("email")) else null; - const status_num = usage.asNumber(resp0.get("status")); - const status = if (status_num) |sn| @as(i64, @intFromFloat(sn)) else -1; + const status = usage.toInt(i64, usage.asNumber(resp0.get("status"))) orelse -1; if (status != 0 and status != 200) { const reason = try std.fmt.allocPrint(alloc, "bailian CLI failed (exit {d})", .{status}); @@ -552,8 +547,7 @@ pub fn advance( if (resp_array.items.len == 1) { const resp0_obj = usage.asObject(resp_array.items[0]); - const status_num = if (resp0_obj) |ro| usage.asNumber(ro.get("status")) else null; - const status = if (status_num) |sn| @as(i64, @intFromFloat(sn)) else -1; + const status = if (resp0_obj) |ro| (usage.toInt(i64, usage.asNumber(ro.get("status"))) orelse -1) else -1; const unavailable = (status == 127); if (!unavailable) { @@ -588,7 +582,7 @@ pub fn advance( return .{ .report = .{ .limits = &[_]usage.UsageLimit{}, - .error_message = "needs a session credential when the host runs no commands; capture a console HAR with scripts/capture-alibaba-session.mjs", + .error_message = "needs a session credential when the host runs no commands; capture a console HAR with prime-agent's scripts/capture-alibaba-session.mjs", }, }; } diff --git a/src/providers/anthropic.zig b/src/providers/anthropic.zig index d193846..d20887a 100644 --- a/src/providers/anthropic.zig +++ b/src/providers/anthropic.zig @@ -1,6 +1,6 @@ //! Anthropic usage feed implementation. //! -//! Mirrors packages/ai/src/utils/usage/providers/anthropic.ts field for field. +//! 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"); @@ -89,7 +89,6 @@ pub fn parseAnthropicUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, _ status; const resets_at_str = usage.asString(limit.get("resets_at")); - // resets_at is converted from ISO-8601 string to epoch milliseconds. const resets_at_ms: ?i64 = if (resets_at_str) |s| iso8601.parseIso8601(s) else null; try limits.append(alloc, .{ @@ -113,19 +112,16 @@ pub fn parseAnthropicUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, _ if (enabled_bool) { const monthly_limit = usage.asNumber(ex.get("monthly_limit")); const used_credits = usage.asNumber(ex.get("used_credits")); - // usedPercent: converted from used_credits / monthly_limit * 100 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| - try std.fmt.allocPrint(alloc, "{d} of {d} credits", .{ - @as(i64, @intFromFloat(used_credits orelse 0.0)), - @as(i64, @intFromFloat(ml)), - }) - else - null; + 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, .{ .label = "Extra usage", @@ -152,12 +148,7 @@ pub fn parseAnthropicUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, _ }; } -fn stringOf(value: ?std.json.Value) ?[]const u8 { - const s = usage.asString(value) orelse return null; - const trimmed = std.mem.trim(u8, s, " \t\r\n"); - if (trimmed.len == 0) return null; - return trimmed; -} + pub fn advance( alloc: std.mem.Allocator, @@ -176,8 +167,8 @@ pub fn advance( return .{ .report = .{ .error_message = "anthropic needs an OAuth account" } }; } const access = usage.asString(cred_root.get("access")) orelse ""; - const cred_email = stringOf(cred_root.get("email")); - const cred_account_id = stringOf(cred_root.get("accountId")); + 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" } }; @@ -207,24 +198,11 @@ pub fn advance( return .{ .requests = requests }; } - const resp0_obj = usage.asObject(resp_array.items[0]); - const status = if (resp0_obj) |ro| usage.asNumber(ro.get("status")) else null; - if (status == null or status.? != 200.0) { - const err_text = if (status) |st| - try std.fmt.allocPrint(alloc, "HTTP {d}", .{@as(i64, @intFromFloat(st))}) - else - "HTTP no response"; - return .{ .report = .{ .error_message = err_text } }; - } - - const body_str = if (resp0_obj) |ro| usage.asString(ro.get("body")) else null; - if (body_str == null) { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; - } - - const body_parsed = std.json.parseFromSlice(std.json.Value, alloc, body_str.?, .{}) catch { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; + 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; @@ -245,11 +223,11 @@ pub fn advance( const parsed_usage = try parseAnthropicUsage(alloc, body_parsed.value, now_ms); const plan_type = if (org_obj) |o| - (stringOf(o.get("seat_tier")) orelse stringOf(o.get("rate_limit_tier"))) + (usage.stringOf(o.get("seat_tier")) orelse usage.stringOf(o.get("rate_limit_tier"))) else null; - const account_email = if (acct_obj) |a| stringOf(a.get("email")) 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); @@ -258,7 +236,7 @@ pub fn advance( const final_account_id = if (cred_account_id) |cid| cid else if (acct_obj) |a| - stringOf(a.get("uuid")) + usage.stringOf(a.get("uuid")) else null; diff --git a/src/providers/antigravity.zig b/src/providers/antigravity.zig index 3b3c4fa..da62940 100644 --- a/src/providers/antigravity.zig +++ b/src/providers/antigravity.zig @@ -1,6 +1,6 @@ //! Google Antigravity usage feed implementation. //! -//! Mirrors packages/ai/src/utils/usage/providers/antigravity.ts field for field. +//! Mirrors prime-agent:packages/ai/src/utils/usage/providers/antigravity.ts field for field. const std = @import("std"); const usage = @import("../usage.zig"); const iso8601 = @import("../iso8601.zig"); @@ -40,7 +40,6 @@ pub fn parseAntigravityUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, const model_id = usage.asString(bucket.get("modelId")) orelse ""; const family = try modelFamily(alloc, model_id) orelse continue; - // Unit converted FROM: remaining-fraction (0..1) const remaining_fraction = usage.asNumber(bucket.get("remainingFraction")); const reset_time = usage.asString(bucket.get("resetTime")); @@ -77,21 +76,18 @@ pub fn parseAntigravityUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, if (!f.info.present) continue; const rem = f.info.min_remaining; - // Unit converted FROM: remaining fraction (0..1) to used percent (0..100) const used_percent: ?f64 = if (rem) |r| @max(0.0, @min(100.0, @round((1.0 - r) * 1000.0) / 10.0)) else null; - // Unit converted FROM: ISO-8601 string to epoch milliseconds const resets_at: ?i64 = if (f.info.reset_time) |rt| iso8601.parseIso8601(rt) else null; const window_label = try usage.epochToWindowLabel(alloc, resets_at, now_ms); - // Detail percentage converted FROM remaining fraction (0..1) - const detail: ?[]const u8 = if (rem) |r| - try std.fmt.allocPrint(alloc, "{d}% free", .{@as(i64, @intFromFloat(@round(r * 100.0)))}) - else - null; + const detail: ?[]const u8 = if (rem) |r| blk: { + const free_pct = usage.roundToInt(i64, r * 100.0) orelse break :blk null; + break :blk try std.fmt.allocPrint(alloc, "{d}% free", .{free_pct}); + } else null; const status = if (used_percent) |up| usage.statusFor(up) else "unknown"; @@ -156,21 +152,11 @@ pub fn advance( return .{ .requests = requests }; } - const resp0_obj = usage.asObject(resp_array.items[0]); - const status = if (resp0_obj) |ro| usage.asNumber(ro.get("status")) else null; - if (status == null or status.? != 200.0) { - const err_text = try usage.httpStatusError(alloc, resp0_obj); - return .{ .report = .{ .error_message = err_text } }; - } - - const body_str = if (resp0_obj) |ro| usage.asString(ro.get("body")) else null; - if (body_str == null) { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; - } - - const body_parsed = std.json.parseFromSlice(std.json.Value, alloc, body_str.?, .{}) catch { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; + const first = switch (try usage.parseFirstResponseEx(alloc, resp_array, "usage body is not JSON", true)) { + .ok => |f| f, + .err => |msg| return .{ .report = .{ .error_message = msg } }, }; + const body_parsed = first.parsed; const limits = try parseAntigravityUsage(alloc, body_parsed.value, now_ms); diff --git a/src/providers/codex.zig b/src/providers/codex.zig index 36a3fc7..a268479 100644 --- a/src/providers/codex.zig +++ b/src/providers/codex.zig @@ -1,6 +1,6 @@ //! OpenAI Codex usage feed implementation. //! -//! Mirrors packages/ai/src/utils/usage/providers/codex.ts field for field. +//! Mirrors prime-agent:packages/ai/src/utils/usage/providers/codex.ts field for field. const std = @import("std"); const usage = @import("../usage.zig"); @@ -13,12 +13,12 @@ fn parseWindow(alloc: std.mem.Allocator, window_val: ?std.json.Value, now_ms: i6 const reset_after = usage.asNumber(value.get("reset_after_seconds")); const limit_window = usage.asNumber(value.get("limit_window_seconds")); - // reset_at > 1e12 is already milliseconds, otherwise seconds (converted from epoch seconds). const reset_ms: ?i64 = if (reset_at) |ra| - (if (ra > 1_000_000_000_000.0) @intFromFloat(ra) else @intFromFloat(ra * 1000.0)) - else if (reset_after) |after| - now_ms + @as(i64, @intFromFloat(after * 1000.0)) - else + (if (ra > 1_000_000_000_000.0) usage.toInt(i64, ra) else usage.toInt(i64, ra * 1000.0)) + else if (reset_after) |after| blk: { + const ms = usage.toInt(i64, after * 1000.0) orelse break :blk null; + break :blk now_ms + ms; + } else null; const window_lbl = try usage.formatWindowLabel(alloc, limit_window); @@ -160,26 +160,12 @@ pub fn advance( return .{ .requests = requests }; } - const resp0_obj = usage.asObject(resp_array.items[0]); - const status = if (resp0_obj) |ro| usage.asNumber(ro.get("status")) else null; - if (status == null or status.? != 200.0) { - const err_text = if (status) |st| - try std.fmt.allocPrint(alloc, "HTTP {d}", .{@as(i64, @intFromFloat(st))}) - else - "HTTP no response"; - return .{ .report = .{ .error_message = err_text } }; - } - - const body_str = if (resp0_obj) |ro| usage.asString(ro.get("body")) else null; - if (body_str == null) { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; - } - - const body_parsed = std.json.parseFromSlice(std.json.Value, alloc, body_str.?, .{}) catch { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; + 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 parsed_usage = try parseCodexUsage(alloc, body_parsed.value, now_ms); + const parsed_usage = try parseCodexUsage(alloc, first.parsed.value, now_ms); return .{ .report = .{ diff --git a/src/providers/deepseek.zig b/src/providers/deepseek.zig index 13cb6bf..9649ed7 100644 --- a/src/providers/deepseek.zig +++ b/src/providers/deepseek.zig @@ -1,6 +1,6 @@ //! DeepSeek usage feed implementation. //! -//! Mirrors packages/ai/src/utils/usage/providers/deepseek.ts field for field. +//! Mirrors prime-agent:packages/ai/src/utils/usage/providers/deepseek.ts field for field. const std = @import("std"); const usage = @import("../usage.zig"); @@ -20,7 +20,6 @@ pub fn parseDeepSeekUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !P const first = usage.asObject(infos.?.items[0]) orelse return ParseDeepSeekResult{}; - // Converted from: total_balance string (e.g. "0.03") or number const total_val = first.get("total_balance"); const total = if (usage.asString(total_val)) |s| s @@ -37,7 +36,6 @@ pub fn parseDeepSeekUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !P break :blk std.ascii.upperString(upper, trimmed); }; - // Converted from: dollar/currency string with currency code (e.g. "$0.03 USD", matching Swift line 171) const symbol = if (std.mem.eql(u8, currency, "CNY")) "¥" else "$"; const balance = try std.fmt.allocPrint(alloc, "{s}{s} {s}", .{ symbol, total, currency }); @@ -102,24 +100,11 @@ pub fn advance( return .{ .requests = requests }; } - const resp0_obj = usage.asObject(resp_array.items[0]); - const status = if (resp0_obj) |ro| usage.asNumber(ro.get("status")) else null; - if (status == null or status.? != 200.0) { - const err_text = if (status) |st| - try std.fmt.allocPrint(alloc, "HTTP {d}", .{@as(i64, @intFromFloat(st))}) - else - "HTTP no response"; - return .{ .report = .{ .error_message = err_text } }; - } - - const body_str = if (resp0_obj) |ro| usage.asString(ro.get("body")) else null; - if (body_str == null) { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; - } - - const body_parsed = std.json.parseFromSlice(std.json.Value, alloc, body_str.?, .{}) catch { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; + 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; const parsed = try parseDeepSeekUsage(alloc, body_parsed.value); if (parsed.balance == null) { diff --git a/src/providers/hyper.zig b/src/providers/hyper.zig index 708490a..e3f3432 100644 --- a/src/providers/hyper.zig +++ b/src/providers/hyper.zig @@ -1,19 +1,12 @@ //! Charm Hyper usage feed implementation. //! -//! Mirrors packages/ai/src/utils/usage/providers/hyper.ts field for field. +//! 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"; -const TeamCache = struct { - team_id: []const u8, - team_name: ?[]const u8 = null, -}; - -var cached_team: ?TeamCache = null; - pub const ParseBillingResult = struct { planType: ?[]const u8 = null, limits: []const usage.UsageLimit = &[_]usage.UsageLimit{}, @@ -47,34 +40,32 @@ pub fn parseHyperBilling( break :blk std.ascii.lowerString(lower, trimmed); } else null; - // Unit converted FROM: hypercredits remaining count (balances.plan.hypercredits) and plan allocation (plan.hypercredits) 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.?); - // Stored as percent (0..100) rounded to 1 decimal place 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; } - // Unit converted FROM: ISO-8601 string (hypercredit_refresh.next_at) to epoch milliseconds const resets_at: ?i64 = if (refresh) |rf| (if (usage.asString(rf.get("next_at"))) |s| iso8601.parseIso8601(s) else null) else null; - // Unit converted FROM: ISO-8601 string (hypercredit_refresh.last_at) to epoch milliseconds 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; if (resets_at != null and last_at != null) { - // Unit converted FROM: difference in milliseconds between next_at and last_at to seconds - const diff_sec = @as(i64, @intFromFloat(@round(@as(f64, @floatFromInt(resets_at.? - last_at.?)) / 1000.0))); - if (diff_sec >= 86_400) { - const days = @as(i64, @intFromFloat(@round(@as(f64, @floatFromInt(diff_sec)) / 86_400.0))); - 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, @as(i64, @intFromFloat(@round(@as(f64, @floatFromInt(diff_sec)) / 3600.0)))); - window_label = try std.fmt.allocPrint(alloc, "{d}h window", .{hours}); + 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; + 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); @@ -92,13 +83,11 @@ pub fn parseHyperBilling( else "ok"; - const detail: ?[]const u8 = if (remaining != null and limit != null) - try std.fmt.allocPrint(alloc, "{d} of {d} credits", .{ - @as(i64, @intFromFloat(remaining.?)), - @as(i64, @intFromFloat(limit.?)), - }) - else - null; + 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, .{ @@ -110,7 +99,6 @@ pub fn parseHyperBilling( .detail = detail, }); - // Unit converted FROM: USD dollar amount string (balances.total.usd) 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"); @@ -129,22 +117,9 @@ pub fn parseHyperBilling( }; } -fn resolveToken(cred_root: std.json.ObjectMap, kind: []const u8) ?[]const u8 { - if (std.mem.eql(u8, kind, "oauth")) { - const acc = usage.asString(cred_root.get("access")) orelse return null; - const trimmed = std.mem.trim(u8, acc, " \t\r\n"); - return if (trimmed.len > 0) trimmed else null; - } - if (std.mem.eql(u8, kind, "apiKey")) { - const key = usage.asString(cred_root.get("apiKey")) orelse return null; - const trimmed = std.mem.trim(u8, key, " \t\r\n"); - return if (trimmed.len > 0) trimmed else null; - } - return null; -} - pub fn advance( alloc: std.mem.Allocator, + state: ?*usage.State, credential_json: []const u8, responses_json: []const u8, now_ms: i64, @@ -156,13 +131,20 @@ pub fn advance( return .{ .report = .{ .error_message = "invalid credential JSON" } }; }; const kind = usage.asString(cred_root.get("kind")) orelse ""; - const token = resolveToken(cred_root, kind); - if (token == null) { - return .{ .report = .{ .error_message = "hyper needs an OAuth or apiKey credential" } }; + 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 cred_account_id = usage.asString(cred_root.get("accountId")); - const cred_email = usage.asString(cred_root.get("email")); + 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" } }; @@ -172,9 +154,10 @@ pub fn advance( }; var auth_hdr_list = std.ArrayList([2][]const u8).empty; - const auth_val = try std.fmt.allocPrint(alloc, "Bearer {s}", .{token.?}); - try auth_hdr_list.append(alloc, .{ "Authorization", auth_val }); + 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] = .{ @@ -185,34 +168,17 @@ pub fn advance( return .{ .requests = requests }; } - const resp0_obj = usage.asObject(resp_array.items[0]); - if (resp0_obj == null) { - return .{ .report = .{ .error_message = "HTTP no response" } }; - } - const status = usage.asNumber(resp0_obj.?.get("status")); - if (status == null or status.? != 200.0) { - const err_text = try usage.httpStatusError(alloc, resp0_obj); - return .{ .report = .{ .error_message = err_text } }; - } - - const body0_str = usage.asString(resp0_obj.?.get("body")); - if (body0_str == null) { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; - } - const body0_parsed = std.json.parseFromSlice(std.json.Value, alloc, body0_str.?, .{}) catch { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; - }; - const body0_obj = usage.asObject(body0_parsed.value) orelse { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; - }; - - const is_billing_response = (body0_obj.get("plan") != null or body0_obj.get("balances") != null); + // Round 2: Teams response received + if (resp_array.items.len == 1) { + const first = switch (try usage.parseFirstResponse(alloc, resp_array, "teams body is not JSON")) { + .ok => |f| f, + .err => |msg| return .{ .report = .{ .error_message = msg } }, + }; - if (!is_billing_response and resp_array.items.len == 1) { - // Teams response - const items = usage.asArray(body0_obj.get("items")); + const teams_root = usage.asObject(first.parsed.value); + 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 teams found for hyper account" } }; + return .{ .report = .{ .error_message = "no hyper teams found" } }; } var selected_team: ?std.json.ObjectMap = null; @@ -238,10 +204,9 @@ pub fn advance( return .{ .report = .{ .error_message = "invalid team in hyper response" } }; } - cached_team = .{ - .team_id = try alloc.dupe(u8, team_id.?), - .team_name = if (team_name) |tn| try alloc.dupe(u8, tn) else null, - }; + 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.? }); @@ -260,15 +225,20 @@ pub fn advance( return .{ .requests = requests }; } - // Billing response handling - const parsed_billing = try parseHyperBilling(alloc, body0_parsed.value, now_ms); + // Round 3: Billing & members responses handling + const first = switch (try usage.parseFirstResponse(alloc, resp_array, "billing body is not JSON")) { + .ok => |f| f, + .err => |msg| return .{ .report = .{ .error_message = msg } }, + }; + + const parsed_billing = try parseHyperBilling(alloc, first.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.asNumber(r1.get("status")); - if (st1 != null and st1.? == 200.0) { + 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| { @@ -289,15 +259,14 @@ pub fn advance( } } - const account_id = cred_account_id orelse (if (cached_team) |ct| ct.team_id else null); + 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_team) |ct| blk: { - if (ct.team_name) |tn| { - const arr = try alloc.alloc([]const u8, 1); - arr[0] = try std.fmt.allocPrint(alloc, "Team: {s}", .{tn}); - break :blk arr; - } - break :blk &[_][]const u8{}; + 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 .{ diff --git a/src/providers/opencode_go.zig b/src/providers/opencode_go.zig index 1cff084..8606744 100644 --- a/src/providers/opencode_go.zig +++ b/src/providers/opencode_go.zig @@ -1,6 +1,6 @@ //! OpenCode Go usage feed implementation. //! -//! Mirrors packages/ai/src/utils/usage/providers/opencode-go.ts field for field. +//! Mirrors prime-agent:packages/ai/src/utils/usage/providers/opencode-go.ts field for field. const std = @import("std"); const usage = @import("../usage.zig"); const iso8601 = @import("../iso8601.zig"); @@ -29,10 +29,8 @@ pub fn parseOpenCodeGoUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) for (OPENCODE_WINDOWS) |win| { const raw_window = usage.asObject(usage_obj.?.get(win.key)) orelse continue; - // Converted from: percentage (0..100) const used_percent = usage.asNumber(raw_window.get("percent")); - // Converted from: ISO-8601 string (e.g. "2026-09-16T04:40:14.037Z") const resets_at_str = usage.asString(raw_window.get("resetsAt")); const resets_at: ?i64 = if (resets_at_str) |s| iso8601.parseIso8601(s) else null; @@ -105,24 +103,11 @@ pub fn advance( return .{ .requests = requests }; } - const resp0_obj = usage.asObject(resp_array.items[0]); - const status = if (resp0_obj) |ro| usage.asNumber(ro.get("status")) else null; - if (status == null or status.? != 200.0) { - const err_text = if (status) |st| - try std.fmt.allocPrint(alloc, "HTTP {d}", .{@as(i64, @intFromFloat(st))}) - else - "HTTP no response"; - return .{ .report = .{ .error_message = err_text } }; - } - - const body_str = if (resp0_obj) |ro| usage.asString(ro.get("body")) else null; - if (body_str == null) { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; - } - - const body_parsed = std.json.parseFromSlice(std.json.Value, alloc, body_str.?, .{}) catch { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; + 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; const limits = try parseOpenCodeGoUsage(alloc, body_parsed.value); diff --git a/src/providers/zai.zig b/src/providers/zai.zig index f6568d2..9d27ba1 100644 --- a/src/providers/zai.zig +++ b/src/providers/zai.zig @@ -1,6 +1,6 @@ //! Z.ai usage feed implementation. //! -//! Mirrors packages/ai/src/utils/usage/providers/zai.ts field for field. +//! Mirrors prime-agent:packages/ai/src/utils/usage/providers/zai.ts field for field. const std = @import("std"); const usage = @import("../usage.zig"); @@ -29,17 +29,14 @@ pub fn parseZaiUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !ParseZ const cur = usage.asNumber(item.get("currentValue")); const total = usage.asNumber(item.get("usage")) orelse usage.asNumber(item.get("number")); - // Converted from: percentage (0..100) or computed from currentValue / total ratio var used_percent = raw_pct; if (used_percent == null and cur != null and total != null and total.? > 0.0) { - // Converted from: currentValue / total ratio used_percent = (cur.? / total.?) * 100.0; } - // Converted from: epoch milliseconds (values > 1e11) or epoch seconds (values <= 1e11) const raw_reset = usage.asNumber(item.get("nextResetTime")); const resets_at: ?i64 = if (raw_reset) |rr| - (if (rr > 100_000_000_000.0) @as(i64, @intFromFloat(rr)) else @as(i64, @intFromFloat(rr * 1000.0))) + (if (rr > 100_000_000_000.0) usage.toInt(i64, rr) else usage.toInt(i64, rr * 1000.0)) else null; @@ -50,10 +47,12 @@ pub fn parseZaiUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !ParseZ if (std.mem.eql(u8, type_str, "TIME_LIMIT") or (unit != null and unit.? == 5.0)) { label = "Requests"; if (cur != null and total != null) { - detail = try std.fmt.allocPrint(alloc, "{d} / {d} reqs", .{ - @as(i64, @intFromFloat(cur.?)), - @as(i64, @intFromFloat(total.?)), - }); + if (usage.toInt(i64, cur.?) != null and usage.toInt(i64, total.?) != null) { + detail = try std.fmt.allocPrint(alloc, "{d} / {d} reqs", .{ + usage.toInt(i64, cur.?).?, + usage.toInt(i64, total.?).?, + }); + } } } else if (std.mem.eql(u8, type_str, "TOKENS_LIMIT") or (unit != null and (unit.? == 3.0 or unit.? == 6.0))) { label = "Tokens"; @@ -136,24 +135,11 @@ pub fn advance( return .{ .requests = requests }; } - const resp0_obj = usage.asObject(resp_array.items[0]); - const status = if (resp0_obj) |ro| usage.asNumber(ro.get("status")) else null; - if (status == null or status.? != 200.0) { - const err_text = if (status) |st| - try std.fmt.allocPrint(alloc, "HTTP {d}", .{@as(i64, @intFromFloat(st))}) - else - "HTTP no response"; - return .{ .report = .{ .error_message = err_text } }; - } - - const body_str = if (resp0_obj) |ro| usage.asString(ro.get("body")) else null; - if (body_str == null) { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; - } - - const body_parsed = std.json.parseFromSlice(std.json.Value, alloc, body_str.?, .{}) catch { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; + 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; const root = usage.asObject(body_parsed.value); const success_val = if (root) |r| r.get("success") else null; @@ -168,7 +154,7 @@ pub fn advance( if (msg) |m| { return .{ .report = .{ .error_message = m } }; } - const err_msg = try std.fmt.allocPrint(alloc, "Z.ai returned code {d}", .{@as(i64, @intFromFloat(code_num orelse 0.0))}); + const err_msg = try std.fmt.allocPrint(alloc, "Z.ai returned code {d}", .{usage.toInt(i64, code_num) orelse 0}); return .{ .report = .{ .error_message = err_msg } }; } diff --git a/src/usage.zig b/src/usage.zig index 513f3f6..3b027fe 100644 --- a/src/usage.zig +++ b/src/usage.zig @@ -1,11 +1,12 @@ //! Pure provider usage query core. //! //! Driven by the host: no sockets, no clock, no filesystem. -//! Implements ABI 2 (host-driven requests/report protocol). +//! Implements ABI 3 (explicit host-owned cross-call state). +//! 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 = 2; +pub const abi_version: u32 = 3; pub const ProviderInfo = struct { provider: []const u8, @@ -56,6 +57,32 @@ pub const supported_providers = [_]ProviderInfo{ }, }; +/// Cross-round state owned by the host and passed back on each advance call. +/// The core owns no cross-call state; the host owns it and passes it back. +pub const State = struct { + hyper_team_id: ?[]u8 = null, + hyper_team_name: ?[]u8 = null, + alloc: std.mem.Allocator, + + pub fn init(alloc: std.mem.Allocator) State { + return .{ .alloc = alloc }; + } + + pub fn deinit(self: *State) void { + if (self.hyper_team_id) |id| self.alloc.free(id); + if (self.hyper_team_name) |name| self.alloc.free(name); + self.hyper_team_id = null; + self.hyper_team_name = null; + } + + pub fn setHyperTeam(self: *State, team_id: []const u8, team_name: ?[]const u8) !void { + if (self.hyper_team_id) |id| self.alloc.free(id); + if (self.hyper_team_name) |name| self.alloc.free(name); + self.hyper_team_id = try self.alloc.dupe(u8, team_id); + self.hyper_team_name = if (team_name) |tn| try self.alloc.dupe(u8, tn) else null; + } +}; + pub const UsageLimit = struct { label: []const u8, usedPercent: ?f64 = null, @@ -84,19 +111,6 @@ pub const UsageRequest = struct { timeoutMs: ?i64 = null, }; -pub const UsageResponse = struct { - status: i64, - body: []const u8, -}; - -pub const UsageCredential = struct { - kind: []const u8, - access: ?[]const u8 = null, - apiKey: ?[]const u8 = null, - accountId: ?[]const u8 = null, - email: ?[]const u8 = null, -}; - pub const Outcome = union(enum) { requests: []const UsageRequest, report: ReportOutcome, @@ -104,7 +118,6 @@ pub const Outcome = union(enum) { }; pub const ReportOutcome = struct { - providerName: ?[]const u8 = null, accountId: ?[]const u8 = null, email: ?[]const u8 = null, planType: ?[]const u8 = null, @@ -149,7 +162,7 @@ pub fn writeJsonString(w: *Writer, value: []const u8) !void { try w.byte('"'); for (value) |c| { switch (c) { - '"' => try w.raw("\\\""), + '"' => try w.raw("\""), '\\' => try w.raw("\\\\"), '\n' => try w.raw("\\n"), '\r' => try w.raw("\\r"), @@ -184,13 +197,17 @@ pub fn asArray(value: ?std.json.Value) ?std.json.Array { pub fn asNumber(value: ?std.json.Value) ?f64 { const v = value orelse return null; - return switch (v) { + const f: ?f64 = switch (v) { .integer => |i| @floatFromInt(i), - .float => |f| f, + .float => |fl| fl, .number_string => |s| std.fmt.parseFloat(f64, s) catch null, .string => |s| std.fmt.parseFloat(f64, s) catch null, else => null, }; + if (f) |num| { + if (std.math.isFinite(num)) return num; + } + return null; } pub fn asString(value: ?std.json.Value) ?[]const u8 { @@ -201,8 +218,31 @@ pub fn asString(value: ?std.json.Value) ?[]const u8 { }; } +pub fn stringOf(value: ?std.json.Value) ?[]const u8 { + const s = asString(value) orelse return null; + const trimmed = std.mem.trim(u8, s, " \t\r\n"); + if (trimmed.len == 0) return null; + return trimmed; +} + +pub fn toInt(comptime T: type, val: ?f64) ?T { + const v = val orelse return null; + if (!std.math.isFinite(v)) return null; + const min_f = @as(f64, @floatFromInt(std.math.minInt(T))); + const max_f = @as(f64, @floatFromInt(std.math.maxInt(T))); + if (v < min_f or v > max_f) return null; + return @as(T, @intFromFloat(v)); +} + +pub fn roundToInt(comptime T: type, val: ?f64) ?T { + const v = val orelse return null; + if (!std.math.isFinite(v)) return null; + return toInt(T, @round(v)); +} + pub fn statusFor(used_percent: ?f64) []const u8 { const used = used_percent orelse return "unknown"; + if (!std.math.isFinite(used)) return "unknown"; if (used >= 100) return "exhausted"; if (used >= 90) return "warning"; return "ok"; @@ -226,31 +266,33 @@ pub fn titleCase(alloc: std.mem.Allocator, value: []const u8) ![]const u8 { pub fn formatWindowLabel(alloc: std.mem.Allocator, seconds: ?f64) !?[]const u8 { const s = seconds orelse return null; - if (!(s > 0)) return null; + if (!std.math.isFinite(s) or !(s > 0)) return null; if (s >= 86_400) { - const days = @as(i64, @intFromFloat(@round(s / 86_400))); + const days = roundToInt(i64, s / 86_400.0) orelse return null; return try std.fmt.allocPrint(alloc, "{d}d window", .{days}); } - const hours = @as(i64, @intFromFloat(@round(s / 3_600))); + const hours = roundToInt(i64, s / 3600.0) orelse return null; return try std.fmt.allocPrint(alloc, "{d}h window", .{if (hours < 1) 1 else hours}); } pub fn epochToWindowLabel(alloc: std.mem.Allocator, resets_at_ms: ?i64, now_ms: i64) !?[]const u8 { const ra = resets_at_ms orelse return null; const diff = ra - now_ms; - const seconds = @max(1, @as(i64, @intFromFloat(@round(@as(f64, @floatFromInt(diff)) / 1000.0)))); + if (diff <= 0) return null; + const diff_f = @as(f64, @floatFromInt(diff)) / 1000.0; + const seconds = @max(1, roundToInt(i64, diff_f) orelse return null); if (seconds >= 86_400) { - const days = @as(i64, @intFromFloat(@round(@as(f64, @floatFromInt(seconds)) / 86_400.0))); + const days = roundToInt(i64, @as(f64, @floatFromInt(seconds)) / 86_400.0) orelse return null; return try std.fmt.allocPrint(alloc, "{d}d window", .{days}); } - const hours = @max(1, @as(i64, @intFromFloat(@round(@as(f64, @floatFromInt(seconds)) / 3600.0)))); + const hours = @max(1, roundToInt(i64, @as(f64, @floatFromInt(seconds)) / 3600.0) orelse return null); return try std.fmt.allocPrint(alloc, "{d}h window", .{hours}); } pub fn httpStatusError(alloc: std.mem.Allocator, response_obj: ?std.json.ObjectMap) ![]const u8 { const ro = response_obj orelse return "HTTP no response"; const status_num = asNumber(ro.get("status")); - const status = if (status_num) |s| @as(i64, @intFromFloat(s)) else return "HTTP no response"; + const status = toInt(i64, status_num) orelse return "HTTP no response"; const body_str = asString(ro.get("body")); if (body_str) |bs| { if (std.json.parseFromSlice(std.json.Value, alloc, bs, .{})) |parsed| { @@ -276,6 +318,51 @@ pub fn httpStatusError(alloc: std.mem.Allocator, response_obj: ?std.json.ObjectM return try std.fmt.allocPrint(alloc, "HTTP {d}", .{status}); } +pub const FirstResponse = struct { + obj: std.json.ObjectMap, + parsed: std.json.Parsed(std.json.Value), +}; + +pub const FirstResponseOutcome = union(enum) { + ok: FirstResponse, + err: []const u8, +}; + +pub fn parseFirstResponse( + alloc: std.mem.Allocator, + resp_array: std.json.Array, + not_json_msg: []const u8, +) !FirstResponseOutcome { + return parseFirstResponseEx(alloc, resp_array, not_json_msg, false); +} + +pub fn parseFirstResponseEx( + alloc: std.mem.Allocator, + resp_array: std.json.Array, + not_json_msg: []const u8, + unwrap_http_error: bool, +) !FirstResponseOutcome { + if (resp_array.items.len == 0) return .{ .err = "HTTP no response" }; + const resp0_obj = asObject(resp_array.items[0]) orelse return .{ .err = "HTTP no response" }; + const status_num = asNumber(resp0_obj.get("status")); + const status = toInt(i64, status_num); + if (status == null or status.? != 200) { + if (unwrap_http_error) { + return .{ .err = try httpStatusError(alloc, resp0_obj) }; + } + const err_text = if (status) |st| + try std.fmt.allocPrint(alloc, "HTTP {d}", .{st}) + else + "HTTP no response"; + return .{ .err = err_text }; + } + const body_str = asString(resp0_obj.get("body")) orelse return .{ .err = not_json_msg }; + const parsed = std.json.parseFromSlice(std.json.Value, alloc, body_str, .{}) catch { + return .{ .err = not_json_msg }; + }; + return .{ .ok = .{ .obj = resp0_obj, .parsed = parsed } }; +} + pub fn urlEncode(alloc: std.mem.Allocator, input: []const u8) ![]const u8 { var out = std.ArrayList(u8).empty; const hex = "0123456789ABCDEF"; @@ -346,13 +433,7 @@ pub fn writeStep(w: *Writer, outcome: Outcome) !void { .report => |rep| { try w.raw("{\"report\":{"); var wrote = false; - if (rep.providerName) |pn| { - try w.raw("\"providerName\":"); - try w.string(pn); - wrote = true; - } if (rep.accountId) |aid| { - if (wrote) try w.byte(','); try w.raw("\"accountId\":"); try w.string(aid); wrote = true; @@ -377,8 +458,10 @@ pub fn writeStep(w: *Writer, outcome: Outcome) !void { try w.raw("{\"label\":"); try w.string(lim.label); if (lim.usedPercent) |up| { - const clamped = @max(0.0, @min(100.0, up)); - try w.print(",\"usedPercent\":{d}", .{clamped}); + if (std.math.isFinite(up)) { + const clamped = @max(0.0, @min(100.0, up)); + try w.print(",\"usedPercent\":{d}", .{clamped}); + } } if (lim.windowLabel) |wl| { try w.raw(",\"windowLabel\":"); @@ -455,36 +538,43 @@ pub const zai = @import("providers/zai.zig"); pub const deepseek = @import("providers/deepseek.zig"); pub const alibaba = @import("providers/alibaba.zig"); +const ProviderDispatch = struct { + name: []const u8, + advance: *const fn (std.mem.Allocator, ?*State, []const u8, []const u8, i64) anyerror!Outcome, +}; + +fn wrapStateless(comptime func: anytype) *const fn (std.mem.Allocator, ?*State, []const u8, []const u8, i64) anyerror!Outcome { + return struct { + fn call(alloc: std.mem.Allocator, state: ?*State, cred: []const u8, resp: []const u8, now_ms: i64) anyerror!Outcome { + _ = state; + return func(alloc, cred, resp, now_ms); + } + }.call; +} + +const provider_table = [_]ProviderDispatch{ + .{ .name = "alibaba", .advance = wrapStateless(alibaba.advance) }, + .{ .name = "anthropic", .advance = wrapStateless(anthropic.advance) }, + .{ .name = "deepseek", .advance = wrapStateless(deepseek.advance) }, + .{ .name = "google-antigravity", .advance = wrapStateless(antigravity.advance) }, + .{ .name = "hyper", .advance = hyper.advance }, + .{ .name = "openai-codex", .advance = wrapStateless(codex.advance) }, + .{ .name = "opencode-go", .advance = wrapStateless(opencode_go.advance) }, + .{ .name = "zai", .advance = wrapStateless(zai.advance) }, +}; + pub fn advance( alloc: std.mem.Allocator, + state: ?*State, provider: []const u8, credential_json: []const u8, responses_json: []const u8, now_ms: i64, ) !Outcome { - if (std.mem.eql(u8, provider, "openai-codex")) { - return codex.advance(alloc, credential_json, responses_json, now_ms); - } - if (std.mem.eql(u8, provider, "anthropic")) { - return anthropic.advance(alloc, credential_json, responses_json, now_ms); - } - if (std.mem.eql(u8, provider, "google-antigravity")) { - return antigravity.advance(alloc, credential_json, responses_json, now_ms); - } - if (std.mem.eql(u8, provider, "hyper")) { - return hyper.advance(alloc, credential_json, responses_json, now_ms); - } - if (std.mem.eql(u8, provider, "opencode-go")) { - return opencode_go.advance(alloc, credential_json, responses_json, now_ms); - } - if (std.mem.eql(u8, provider, "zai")) { - return zai.advance(alloc, credential_json, responses_json, now_ms); - } - if (std.mem.eql(u8, provider, "deepseek")) { - return deepseek.advance(alloc, credential_json, responses_json, now_ms); - } - if (std.mem.eql(u8, provider, "alibaba")) { - return alibaba.advance(alloc, credential_json, responses_json, now_ms); + inline for (provider_table) |entry| { + if (std.mem.eql(u8, provider, entry.name)) { + return entry.advance(alloc, state, credential_json, responses_json, now_ms); + } } return .{ .err = "unknown provider" }; } diff --git a/swift/Sources/UsageCore/UsageCore.swift b/swift/Sources/UsageCore/UsageCore.swift index b381c58..a648d34 100644 --- a/swift/Sources/UsageCore/UsageCore.swift +++ b/swift/Sources/UsageCore/UsageCore.swift @@ -121,24 +121,51 @@ public enum UsageStep: Sendable { } } +/// Opaque cross-round state managed by the host. +public final class UsageState: @unchecked Sendable { + let pointer: OpaquePointer? + + public init() { + self.pointer = usage_state_new() + } + + deinit { + if let pointer { + usage_state_free(pointer) + } + } +} + public enum UsageCoreError: Error, CustomStringConvertible { case missingLibraryResult case invalidJSON(String) case providerRefused(String) + case incompatibleAbiVersion(expected: UInt32, actual: UInt32) public var description: String { switch self { case .missingLibraryResult: return "the usage core returned nothing" case .invalidJSON(let text): return "unexpected JSON from the usage core: \(text)" case .providerRefused(let message): return message + case .incompatibleAbiVersion(let expected, let actual): + return "incompatible usage core ABI version: expected \(expected), got \(actual)" } } } public enum UsageCore { + public static let expectedAbiVersion: UInt32 = 3 public static var abiVersion: UInt32 { usage_abi_version() } + public static func checkAbiVersion() throws { + let version = abiVersion + guard version == expectedAbiVersion else { + throw UsageCoreError.incompatibleAbiVersion(expected: expectedAbiVersion, actual: version) + } + } + public static func providers() throws -> [UsageProviderInfo] { + try checkAbiVersion() let json = try consume(usage_providers()) return try JSONDecoder().decode([UsageProviderInfo].self, from: Data(json.utf8)) } @@ -146,11 +173,13 @@ public enum UsageCore { /// One step of the host loop: hand the responses you already have (none on the first call) and /// get back either the requests to send or the report. public static func advance( + state: UsageState? = nil, provider: String, credentialJSON: String, responses: [UsageResponse], nowMs: Int64 ) throws -> UsageStep { + try checkAbiVersion() let responsesJSON = String(data: try JSONEncoder().encode(responses), encoding: .utf8) ?? "[]" let raw = try consume( @@ -158,6 +187,7 @@ public enum UsageCore { credentialJSON.withCString { credentialPtr in responsesJSON.withCString { responsesPtr in usage_advance( + state?.pointer, providerPtr, strlen(providerPtr), credentialPtr, strlen(credentialPtr), responsesPtr, strlen(responsesPtr), diff --git a/swift/Sources/UsageCore/UsageDriver.swift b/swift/Sources/UsageCore/UsageDriver.swift index 2afc21a..0290efd 100644 --- a/swift/Sources/UsageCore/UsageDriver.swift +++ b/swift/Sources/UsageCore/UsageDriver.swift @@ -14,10 +14,11 @@ public enum UsageDriver { runCommand: CommandRunner? = nil, maxRounds: Int = 4 ) async throws -> UsageReport { + let state = UsageState() var responses: [UsageResponse] = [] for _ in 0.. C ABI -> core path, with no network: the responses come from the recorded // fixtures the TypeScript and Zig implementations are compared against. // Pass the fixtures directory as argv[1], or let the two likely working directories decide: the -// package directory (`swift run` from swift/) or native/usage (the repo check script). +// package directory (`swift run` from swift/) or repository root. func locateFixtures() -> URL { if CommandLine.arguments.count > 1 { return URL(fileURLWithPath: CommandLine.arguments[1]) @@ -35,7 +35,7 @@ func check(_ condition: Bool, _ message: String) { } do { - check(UsageCore.abiVersion == 2, "unexpected ABI version \(UsageCore.abiVersion)") + check(UsageCore.abiVersion == 3, "unexpected ABI version \(UsageCore.abiVersion)") let providers = try UsageCore.providers() check(providers.contains { $0.provider == "openai-codex" }, "codex should be a known provider") @@ -59,8 +59,7 @@ do { check(codexReport.limits.count == 3, "expected three windows, got \(codexReport.limits.count)") check(codexReport.limits.first?.status == "exhausted", "the primary window is at 100%") - // anthropic: the ISO reset time has to come back as epoch milliseconds, and the report comes from - // the second round because the profile is fetched alongside the usage body. + // anthropic: round 1 fetches usage and profile concurrently; round 2 reports with ISO reset converted to epoch ms. let anthropicCredential = UsageDriver.credentialJSON(kind: "oauth", value: "test-token") let anthropicStep = try UsageCore.advance(provider: "anthropic", credentialJSON: anthropicCredential, responses: [], nowMs: 1_700_000_000_000) guard case .requests(let anthropicRequests) = anthropicStep else { fail("anthropic should ask for requests") } @@ -151,6 +150,29 @@ do { check(alibabaReport.notes?.contains(where: { $0.contains("days remaining in the plan") }) == true, "notes carry remaining days") check(alibabaReport.notes?.contains(where: { $0.contains("Token plan pro") }) == true, "notes carry quota config") + // hyper: multi-round flow with UsageState preserving team across calls + let hyperState = UsageState() + let hyperCredential = UsageDriver.credentialJSON(kind: "apiKey", value: "sk-hyper-test") + let hyperStep0 = try UsageCore.advance(state: hyperState, provider: "hyper", credentialJSON: hyperCredential, responses: [], nowMs: 1_789_514_000_000) + guard case .requests(let hyperReq0) = hyperStep0 else { fail("hyper should ask for teams first") } + check(hyperReq0.count == 1, "hyper round 1 asks for teams") + check(hyperReq0[0].url.hasSuffix("/v1/teams"), "teams url") + + let hyperTeamsResponse = [UsageResponse(status: 200, body: try fixture("hyper-teams.json"))] + let hyperStep1 = try UsageCore.advance(state: hyperState, provider: "hyper", credentialJSON: hyperCredential, responses: hyperTeamsResponse, nowMs: 1_789_514_000_000) + guard case .requests(let hyperReq1) = hyperStep1 else { fail("hyper round 2 should ask for billing and members") } + check(hyperReq1.count == 2, "hyper asks for billing and members") + + let hyperDataResponses = [ + UsageResponse(status: 200, body: try fixture("hyper-billing.json")), + UsageResponse(status: 200, body: try fixture("hyper-members.json")), + ] + guard let hyperReport = try UsageCore.advance(state: hyperState, provider: "hyper", credentialJSON: hyperCredential, responses: hyperDataResponses, nowMs: 1_789_514_000_000).reportValue else { + fail("hyper should report") + } + check(hyperReport.planType == "shred", "hyper planType shred") + check(hyperReport.notes?.contains("Team: Example Team") == true, "hyper preserved team name in state across rounds") + // An unknown provider is an error, not an empty report. do { _ = try UsageCore.advance(provider: "nope", credentialJSON: "{}", responses: [], nowMs: 0) @@ -159,7 +181,7 @@ do { check(message.contains("unknown provider"), "unexpected message: \(message)") } - print("usage-smoke: abi=\(UsageCore.abiVersion) providers=\(providers.count) codex=\(codexReport.limits.count) anthropic=\(anthropicReport.limits.count) antigravity=\(antigravityReport.limits.count) alibaba=\(alibabaRequests.count) calls OK") + print("usage-smoke: abi=\(UsageCore.abiVersion) providers=\(providers.count) codex=\(codexReport.limits.count) anthropic=\(anthropicReport.limits.count) hyper=\(hyperReport.limits.count) antigravity=\(antigravityReport.limits.count) alibaba=\(alibabaRequests.count) calls OK") } catch { fail("\(error)") } diff --git a/tests/c_smoke.c b/tests/c_smoke.c index 3e6fec9..babc04c 100644 --- a/tests/c_smoke.c +++ b/tests/c_smoke.c @@ -3,6 +3,13 @@ #include #include "usage.h" +static void check(int condition, const char *msg) { + if (!condition) { + fprintf(stderr, "FAIL: %s\n", msg); + exit(1); + } +} + static char *read_file(const char *path) { FILE *f = fopen(path, "rb"); if (!f) return NULL; @@ -16,45 +23,113 @@ static char *read_file(const char *path) { return buffer; } +static char *wrap_response_json(const char *body) { + size_t body_len = strlen(body); + size_t cap = body_len * 2 + 64; + char *out = malloc(cap); + strcpy(out, "[{\"status\":200,\"body\":\""); + size_t pos = strlen(out); + for (size_t i = 0; i < body_len; i++) { + char c = body[i]; + if (c == '"') { + out[pos++] = '\\'; + out[pos++] = '"'; + } else if (c == '\\') { + out[pos++] = '\\'; + out[pos++] = '\\'; + } else if (c == '\n') { + out[pos++] = '\\'; + out[pos++] = 'n'; + } else if (c == '\r') { + out[pos++] = '\\'; + out[pos++] = 'r'; + } else if (c == '\t') { + out[pos++] = '\\'; + out[pos++] = 't'; + } else { + out[pos++] = c; + } + } + out[pos++] = '"'; + out[pos++] = '}'; + out[pos++] = ']'; + out[pos] = '\0'; + return out; +} + int main(int argc, char **argv) { if (argc < 2) return 2; + + check(usage_abi_version() == 3, "ABI version must be 3"); + char *providers = usage_providers(); - printf("abi=%u providers=%s\n", usage_abi_version(), providers); + check(providers != NULL, "providers must not be NULL"); + check(strstr(providers, "\"provider\":\"openai-codex\"") != NULL, "codex provider present"); + check(strstr(providers, "\"provider\":\"hyper\"") != NULL, "hyper provider present"); usage_free(providers); char *body = read_file(argv[1]); - if (!body) { fprintf(stderr, "cannot read %s\n", argv[1]); return 2; } + check(body != NULL, "cannot read fixture file"); + /* 1. Codex step 1: asks for request */ const char *cred = "{\"kind\":\"oauth\",\"access\":\"tok\",\"email\":\"user@example.com\"}"; - char *first_step = usage_advance("openai-codex", strlen("openai-codex"), cred, strlen(cred), "[]", 2, 0); - printf("first_step=%s\n", first_step); + char *first_step = usage_advance(NULL, "openai-codex", strlen("openai-codex"), cred, strlen(cred), "[]", 2, 0); + check(first_step != NULL, "first step should not be NULL"); + check(strstr(first_step, "\"url\":\"https://chatgpt.com/backend-api/wham/usage\"") != NULL, "codex url"); + check(strstr(first_step, "\"kind\":\"http\"") != NULL, "codex request kind http"); usage_free(first_step); - /* Construct responses json: [{"status":200,"body": }] */ - /* For simplicity in smoke test, build a small JSON wrapper around body */ - size_t resp_len = strlen(body) + 64; - char *resp = malloc(resp_len); - /* Serialize body inside responses JSON string properly */ - snprintf(resp, resp_len, "[{\"status\":200,\"body\":"); - /* If body is already json, we can test advance with a simple response or pass raw */ - /* But responses expects an array of {status, body: string} */ - /* Let's construct a minimal valid responses_json */ - free(resp); - - const char *resp_json = "[{\"status\":200,\"body\":\"{\\\"plan_type\\\":\\\"plus\\\"}\"}]"; - char *report = usage_advance("openai-codex", strlen("openai-codex"), cred, strlen(cred), resp_json, strlen(resp_json), 1789514000000LL); - printf("report=%s\n", report); + /* 2. Codex step 2: drives recorded body from argv[1] (codex-usage-live.json) */ + char *resp_json = wrap_response_json(body); + char *report = usage_advance(NULL, "openai-codex", strlen("openai-codex"), cred, strlen(cred), resp_json, strlen(resp_json), 1789514000000LL); + check(report != NULL, "report should not be NULL"); + check(strstr(report, "\"planType\":\"prolite\"") != NULL, "codex planType prolite"); + check(strstr(report, "\"label\":\"Primary\"") != NULL, "codex Primary limit"); + check(strstr(report, "\"status\":\"exhausted\"") != NULL, "codex exhausted status"); + check(strstr(report, "\"email\":\"user@example.com\"") != NULL, "codex email"); usage_free(report); + free(resp_json); + free(body); - char *unknown = usage_advance("nonexistent", strlen("nonexistent"), cred, strlen(cred), "[]", 2, 0); - printf("unknown=%s\n", unknown); + /* 3. Hyper multi-round state test: team id and name survive across rounds */ + usage_state *state = usage_state_new(); + check(state != NULL, "usage_state_new failed"); + + const char *hyper_cred = "{\"kind\":\"apiKey\",\"apiKey\":\"sk-hyper-test\"}"; + const char *teams_resp = "[{\"status\":200,\"body\":\"{\\\"items\\\":[{\\\"id\\\":\\\"team_live_1\\\",\\\"name\\\":\\\"Smoke Team\\\"}]}\"}]"; + char *hyper_step1 = usage_advance(state, "hyper", strlen("hyper"), hyper_cred, strlen(hyper_cred), teams_resp, strlen(teams_resp), 0); + check(hyper_step1 != NULL, "hyper round 1 should succeed"); + check(strstr(hyper_step1, "team_live_1/billing") != NULL, "hyper asked for billing"); + usage_free(hyper_step1); + + const char *billing_resp = "[{\"status\":200,\"body\":\"{\\\"status\\\":\\\"active\\\",\\\"plan\\\":{\\\"name\\\":\\\"Shred\\\",\\\"hypercredits\\\":100}}\"},{\"status\":200,\"body\":\"{\\\"items\\\":[]}\"}]"; + char *hyper_step2 = usage_advance(state, "hyper", strlen("hyper"), hyper_cred, strlen(hyper_cred), billing_resp, strlen(billing_resp), 0); + check(hyper_step2 != NULL, "hyper round 2 should succeed"); + check(strstr(hyper_step2, "\"accountId\":\"team_live_1\"") != NULL, "hyper preserved team_id"); + check(strstr(hyper_step2, "Team: Smoke Team") != NULL, "hyper preserved team_name"); + usage_free(hyper_step2); + + usage_state_free(state); + + /* 4. Hyper round 2 with NULL state: does not crash, but cannot emit team name */ + char *hyper_no_state = usage_advance(NULL, "hyper", strlen("hyper"), hyper_cred, strlen(hyper_cred), billing_resp, strlen(billing_resp), 0); + check(hyper_no_state != NULL, "hyper with NULL state should not crash"); + check(strstr(hyper_no_state, "Team: Smoke Team") == NULL, "without state, team name is absent"); + usage_free(hyper_no_state); + + /* 5. Unknown provider */ + char *unknown = usage_advance(NULL, "nonexistent", strlen("nonexistent"), cred, strlen(cred), "[]", 2, 0); + check(unknown != NULL && strstr(unknown, "unknown provider") != NULL, "unknown provider handled"); usage_free(unknown); - char *none = usage_advance(NULL, 0, NULL, 0, NULL, 0, 0); - printf("null-safe=%s\n", none == NULL ? "null" : "ok"); + /* 6. Null-safe advance */ + char *none = usage_advance(NULL, NULL, 0, NULL, 0, NULL, 0, 0); + check(none != NULL && strstr(none, "unknown provider") != NULL, "null provider handled safely"); usage_free(none); + usage_free(NULL); + usage_state_free(NULL); - free(body); + printf("c_smoke: all assertions passed\n"); return 0; } diff --git a/tests/usage_test.zig b/tests/usage_test.zig index 43bb109..87e2143 100644 --- a/tests/usage_test.zig +++ b/tests/usage_test.zig @@ -5,13 +5,17 @@ const iso8601 = usage.iso8601; const jwt = "eyJhbGciOiJSUzI1NiJ9.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdF90ZXN0XzEifSwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9wcm9maWxlIjp7ImVtYWlsIjoiVGVzdC5BY2NvdW50QEV4YW1wbGUuQ09NIn19.c2ln"; -fn advance(alloc: std.mem.Allocator, provider: []const u8, cred: []const u8, resp: []const u8, now_ms: i64) ![]const u8 { +fn advanceWithState(alloc: std.mem.Allocator, state: ?*usage.State, provider: []const u8, cred: []const u8, resp: []const u8, now_ms: i64) ![]const u8 { var w = usage.Writer.init(alloc); - const outcome = try usage.advance(alloc, provider, cred, resp, now_ms); + const outcome = try usage.advance(alloc, state, provider, cred, resp, now_ms); try usage.writeStep(&w, outcome); return w.items(); } +fn advance(alloc: std.mem.Allocator, provider: []const u8, cred: []const u8, resp: []const u8, now_ms: i64) ![]const u8 { + return advanceWithState(alloc, null, provider, cred, resp, now_ms); +} + test "iso-8601 parser formats" { const cases = [_]struct { s: []const u8, expected: i64 }{ .{ .s = "2026-09-16T08:00:00.037593+00:00", .expected = 1789545600037 }, @@ -184,17 +188,20 @@ test "hyper two-step flow and unit conversion" { defer arena.deinit(); const a = arena.allocator(); + var state = usage.State.init(a); + defer state.deinit(); + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"sk-hyper-123\"}"; // Step 0 -> /v1/teams - const step0 = try advance(a, "hyper", cred, "[]", 0); + const step0 = try advanceWithState(a, &state, "hyper", cred, "[]", 0); try std.testing.expect(std.mem.indexOf(u8, step0, "https://hyper.charm.land/v1/teams") != null); // Step 1 -> receives teams, asks for billing and members const teams_resp = \\[{"status":200,"body":"{\"items\":[{\"id\":\"team_123\",\"name\":\"My Team\"}]}"}] ; - const step1 = try advance(a, "hyper", cred, teams_resp, 0); + const step1 = try advanceWithState(a, &state, "hyper", cred, teams_resp, 0); try std.testing.expect(std.mem.indexOf(u8, step1, "https://hyper.charm.land/v1/teams/team_123/billing") != null); try std.testing.expect(std.mem.indexOf(u8, step1, "https://hyper.charm.land/v1/teams/team_123/members") != null); @@ -202,7 +209,7 @@ test "hyper two-step flow and unit conversion" { const billing_resp = \\[{"status":200,"body":"{\"status\":\"active\",\"plan\":{\"name\":\"Shred\",\"hypercredits\":250},\"balances\":{\"plan\":{\"hypercredits\":\"237\"},\"total\":{\"usd\":\"11.83\"}},\"hypercredit_refresh\":{\"last_at\":\"2026-09-15T22:26:09Z\",\"next_at\":\"2026-09-16T22:26:09Z\"}}"},{"status":200,"body":"{\"items\":[{\"email\":\"user@example.com\"}]}"}] ; - const step2 = try advance(a, "hyper", cred, billing_resp, 0); + const step2 = try advanceWithState(a, &state, "hyper", cred, billing_resp, 0); try std.testing.expect(std.mem.indexOf(u8, step2, "\"planType\":\"shred\"") != null); try std.testing.expect(std.mem.indexOf(u8, step2, "\"accountId\":\"team_123\"") != null); try std.testing.expect(std.mem.indexOf(u8, step2, "\"email\":\"user@example.com\"") != null); @@ -367,6 +374,7 @@ test "alibaba 127 without session error" { const text = try advance(a, "alibaba", cred, resp_127, 0); try std.testing.expect(std.mem.indexOf(u8, text, "capture-alibaba-session.mjs") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "prime-agent") != null); } test "alibaba missing sec_token is omitted from form body" { @@ -404,3 +412,106 @@ test "alibaba console answers: plan, remaining days, tier credits and reset card try std.testing.expect(std.mem.indexOf(u8, text, "29 days remaining in the plan") != null); try std.testing.expect(std.mem.indexOf(u8, text, "1 reset cards available") != null); } + + +test "hyper state persists team across rounds even when arena is freed" { + var state = usage.State.init(std.testing.allocator); + defer state.deinit(); + + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"sk-hyper-123\"}"; + const teams_resp = + \\[{"status":200,"body":"{\"items\":[{\"id\":\"team_live_123\",\"name\":\"Hyper Squad\"}]}"}] + ; + + // Round 1 in its own arena, which is deinitialized immediately after + { + var arena1 = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena1.deinit(); + const step1 = try advanceWithState(arena1.allocator(), &state, "hyper", cred, teams_resp, 0); + try std.testing.expect(std.mem.indexOf(u8, step1, "team_live_123/billing") != null); + } + // arena1 is now dead and its memory is released + + // Round 2 in a fresh arena: must read team_id and team_name from state, not freed memory + { + var arena2 = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena2.deinit(); + const billing_resp = + \\[{"status":200,"body":"{\"status\":\"active\",\"plan\":{\"name\":\"Pro\",\"hypercredits\":500},\"balances\":{\"plan\":{\"hypercredits\":\"450\"},\"total\":{\"usd\":\"25.00\"}}}"},{"status":200,"body":"{\"items\":[{\"email\":\"member@example.com\"}]}"}] + ; + const step2 = try advanceWithState(arena2.allocator(), &state, "hyper", cred, billing_resp, 0); + try std.testing.expect(std.mem.indexOf(u8, step2, "\"accountId\":\"team_live_123\"") != null); + try std.testing.expect(std.mem.indexOf(u8, step2, "Team: Hyper Squad") != null); + try std.testing.expect(std.mem.indexOf(u8, step2, "\"planType\":\"pro\"") != null); + } + + // Passing NULL state to round 2 does not panic, but cannot emit team name + { + var arena3 = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena3.deinit(); + const billing_resp = + \\[{"status":200,"body":"{\"status\":\"active\",\"plan\":{\"name\":\"Pro\",\"hypercredits\":500},\"balances\":{\"plan\":{\"hypercredits\":\"450\"}}}"},{"status":200,"body":"{\"items\":[]}"}] + ; + const step_no_state = try advanceWithState(arena3.allocator(), null, "hyper", cred, billing_resp, 0); + try std.testing.expect(std.mem.indexOf(u8, step_no_state, "Team: Hyper Squad") == null); + } +} + +test "number safety: NaN does not panic integer cast or emit invalid JSON" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"oauth\",\"access\":\"tok\"}"; + const nan_body = + \\[{"status":200,"body":"{\"plan_type\":\"plus\",\"rate_limit\":{\"primary_window\":{\"used_percent\":\"nan\",\"reset_at\":\"nan\"}}}"}] + ; + const text = try advance(a, "openai-codex", cred, nan_body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"planType\":\"plus\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, ":nan") == null); + try std.testing.expect(std.mem.indexOf(u8, text, ":NaN") == null); + _ = try std.json.parseFromSlice(std.json.Value, a, text, .{}); +} + +test "number safety: inf does not serialize as usedPercent:inf" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"oauth\",\"access\":\"tok\"}"; + const inf_body = + \\[{"status":200,"body":"{\"plan_type\":\"plus\",\"rate_limit\":{\"primary_window\":{\"used_percent\":\"inf\"}}}"}] + ; + const text = try advance(a, "openai-codex", cred, inf_body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, ":inf") == null); + try std.testing.expect(std.mem.indexOf(u8, text, ":Infinity") == null); + _ = try std.json.parseFromSlice(std.json.Value, a, text, .{}); +} + +test "number safety: out-of-range 1e30 does not panic integer cast" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"key\"}"; + const big_body = + \\[{"status":200,"body":"{\"code\":200,\"success\":true,\"data\":{\"level\":\"pro\",\"limits\":[{\"type\":\"TIME_LIMIT\",\"percentage\":50,\"nextResetTime\":1e30,\"currentValue\":1e30,\"usage\":1e30}]}}"}] + ; + const text = try advance(a, "zai", cred, big_body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"planType\":\"Pro\"") != null); + _ = try std.json.parseFromSlice(std.json.Value, a, text, .{}); +} + +test "number safety: non-number string in numeric field does not panic" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"key\"}"; + const string_body = + \\[{"status":200,"body":"{\"code\":200,\"success\":true,\"data\":{\"level\":\"pro\",\"limits\":[{\"type\":\"TIME_LIMIT\",\"percentage\":\"not-a-number\",\"currentValue\":\"bad\"}]}}"}] + ; + const text = try advance(a, "zai", cred, string_body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"planType\":\"Pro\"") != null); + _ = try std.json.parseFromSlice(std.json.Value, a, text, .{}); +} -- 2.51.2