diff --git a/README.md b/README.md index 5e832b5..4131edb 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,32 @@ implementation fails the test rather than drifting quietly. The fixtures under `chatgpt.com/backend-api/wham/usage` with the `user_id`, `account_id`, and `email` fields removed. +## Swift + +`swift/` wraps this library for native macOS hosts (dawn's tray app is the first consumer): + +```sh +zig build # produces zig-out/lib/libusage.a +swift build --package-path native/usage/swift +swift run --package-path native/usage/swift usage-smoke +``` + +- `UsageCore` is the thin C bridge: `providers()`, `advance(provider:credentialJSON:responses:nowMs:)` + returning either the requests or the report, and `credentialsFromAccessToken`. Every returned + string is freed with `usage_free`, and a NULL return becomes a thrown error rather than a crash. +- `UsageDriver` runs the host loop with `URLSession`: ask the core for requests, send them, hand the + responses back, repeat. A transport failure comes back as a response with status `0`, so the feed + reports it as data and the row shows the reason instead of the call throwing. +- `usage-smoke` proves the path against the recorded fixtures with no network: ABI version, provider + list, the codex request shape, the codex report, anthropic's ISO reset time becoming epoch + milliseconds, the warning status, the plan type from the profile, and the unknown-provider error. + +Headers cross the ABI as ordered pairs because HTTP allows a repeated name; `UsageRequest.headers` +is the dictionary view for the common case. + +The library is built for the host's deployment target. For a macOS 13 app, build it with +`zig build -Dtarget=aarch64-macos.13`. + ## Status - One provider has a feed: `openai-codex`. Others return diff --git a/include/usage.h b/include/usage.h index 2dce81a..7bd0c79 100644 --- a/include/usage.h +++ b/include/usage.h @@ -2,7 +2,7 @@ * * The core is pure: it never opens a socket, reads a clock, or touches the * filesystem. The host does the I/O, so a macOS app can use NSURLSession (system - * trust store, proxy support, HTTP/2) and hand the body back for parsing. + * trust store, proxy support, HTTP/2) and hand the body back. * * Every function returns a NUL-terminated JSON string owned by the library, or * NULL when allocation failed. Failures are reported inside the JSON as @@ -19,27 +19,28 @@ extern "C" { #endif -/* ABI version. Bump on any change to the exported signatures or their shapes. */ +/* ABI version. Bumped to 2 for host-driven requests/report protocol. */ uint32_t usage_abi_version(void); /* Release a string returned by this library. NULL is accepted. */ void usage_free(char *ptr); -/* {"accountId"?: string, "email"?: string} from an OAuth access token's claims. */ -char *usage_credentials_from_access_token(const char *jwt, size_t jwt_len); - -/* {"url": string, "headers": [[name, value], ...]} for a provider's usage request. - * account_id may be NULL. */ -char *usage_build_request(const char *provider, size_t provider_len, - const char *access_token, size_t access_token_len, - const char *account_id, size_t account_id_len); +/* Provider definitions: [{"provider","providerName","credentialKinds"}] */ +char *usage_providers(void); -/* {"planType"?: string, "limits": [{label, usedPercent?, windowLabel?, resetsAt?, status}]} */ -char *usage_parse(const char *provider, size_t provider_len, - const char *body, size_t body_len, int64_t now_ms); +/* Advance feed state. + * Returns either: + * {"requests":[{"method","url","headers":[["k","v"]],"body"?}]} + * or: + * {"report":{providerName, accountId?, email?, planType?, limits[], balance?, notes?[], error?}} + */ +char *usage_advance(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); -/* Provider ids with a usage feed. */ -char *usage_supported_providers(void); +/* {"accountId"?: string, "email"?: string} from an OAuth access token's claims. */ +char *usage_credentials_from_access_token(const char *jwt, size_t jwt_len); #ifdef __cplusplus } diff --git a/src/c_api.zig b/src/c_api.zig index 9ba1a3b..c2cc4d6 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -42,72 +42,51 @@ export fn usage_free(ptr: ?[*:0]u8) void { if (ptr) |p| alloc.free(std.mem.span(p)); } -export fn usage_supported_providers() ?[*:0]u8 { +export fn usage_providers() ?[*:0]u8 { var arena = std.heap.ArenaAllocator.init(alloc); defer arena.deinit(); const a = arena.allocator(); var w = usage.Writer.init(a); - w.raw("[") catch return null; - for (usage.supported_providers, 0..) |provider, i| { - if (i > 0) w.raw(",") catch return null; - w.string(provider) catch return null; - } - w.raw("]") catch return null; + usage.writeProviders(&w) catch return null; return finish(&w); } -export fn usage_parse( +export fn usage_advance( provider: ?[*]const u8, provider_len: usize, - body: ?[*]const u8, - body_len: usize, + credential_json: ?[*]const u8, + credential_len: usize, + responses_json: ?[*]const u8, + responses_len: usize, now_ms: i64, ) ?[*:0]u8 { var arena = std.heap.ArenaAllocator.init(alloc); defer arena.deinit(); const a = arena.allocator(); - var w = usage.Writer.init(a); - const outcome = usage.parse(a, slice(provider, provider_len), slice(body, body_len), now_ms, &w) catch |e| { + const prov_slice = slice(provider, provider_len); + const cred_slice = slice(credential_json, credential_len); + const resp_slice = slice(responses_json, responses_len); + + const outcome = usage.advance(a, prov_slice, cred_slice, resp_slice, now_ms) catch |e| { return errorResult(a, message(e)); }; - switch (outcome) { - .ok => return finish(&w), - .err => |text| return errorResult(a, text), - } -} - -export fn usage_credentials_from_access_token(jwt: ?[*]const u8, jwt_len: usize) ?[*:0]u8 { - var arena = std.heap.ArenaAllocator.init(alloc); - defer arena.deinit(); - const a = arena.allocator(); var w = usage.Writer.init(a); - const outcome = usage.claimsFromAccessToken(a, slice(jwt, jwt_len), &w) catch |e| { + usage.writeStep(&w, outcome) catch |e| { return errorResult(a, message(e)); }; - switch (outcome) { - .ok => return finish(&w), - .err => |text| return errorResult(a, text), - } + return finish(&w); } -export fn usage_build_request( - provider: ?[*]const u8, - provider_len: usize, - access_token: ?[*]const u8, - access_token_len: usize, - account_id: ?[*]const u8, - account_id_len: usize, -) ?[*:0]u8 { +export fn usage_credentials_from_access_token(jwt: ?[*]const u8, jwt_len: usize) ?[*:0]u8 { var arena = std.heap.ArenaAllocator.init(alloc); defer arena.deinit(); const a = arena.allocator(); - const account_slice: ?[]const u8 = if (account_id) |id| id[0..account_id_len] else null; var w = usage.Writer.init(a); - const outcome = usage.buildRequest(slice(provider, provider_len), slice(access_token, access_token_len), account_slice, &w) catch |e| { + const outcome = usage.claimsFromAccessToken(a, slice(jwt, jwt_len), &w) catch |e| { return errorResult(a, message(e)); }; switch (outcome) { diff --git a/src/iso8601.zig b/src/iso8601.zig new file mode 100644 index 0000000..e0e259b --- /dev/null +++ b/src/iso8601.zig @@ -0,0 +1,127 @@ +//! ISO-8601 parser for usage feed reset timestamps. +//! +//! Handles full ISO-8601 strings, e.g.: +//! - 2026-09-16T08:00:00.037593+00:00 +//! - 2026-09-15T23:46:00Z +//! - 2026-06-09T11:28:39.481128Z +//! - 2026-01-01T00:00:00+05:30 +//! - 2026-01-01T00:00:00-08:00 +//! +//! Returns epoch milliseconds (UTC), matching Date.parse in TypeScript. +const std = @import("std"); + +fn isLeapYear(year: i64) bool { + if (@mod(year, 4) != 0) return false; + if (@mod(year, 100) != 0) return true; + return @mod(year, 400) == 0; +} + +const days_before_month_common = [_]i64{ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 }; +const days_before_month_leap = [_]i64{ 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 }; + +pub fn daysFromCivil(year: i64, month: u8, day: u8) i64 { + // Days from 1970-01-01 to start of `year` + var leaps: i64 = 0; + if (year >= 1970) { + const y_prev = year - 1; + leaps = @divFloor(y_prev, 4) - @divFloor(y_prev, 100) + @divFloor(y_prev, 400) - + (@divFloor(1969, 4) - @divFloor(1969, 100) + @divFloor(1969, 400)); + } else { + const y_curr = year; + leaps = @divFloor(1969, 4) - @divFloor(1969, 100) + @divFloor(1969, 400) - + (@divFloor(y_curr - 1, 4) - @divFloor(y_curr - 1, 100) + @divFloor(y_curr - 1, 400)); + leaps = -leaps; + } + const days_in_years = (year - 1970) * 365 + leaps; + const m_days = if (isLeapYear(year)) days_before_month_leap[month - 1] else days_before_month_common[month - 1]; + return days_in_years + m_days + (day - 1); +} + +pub fn parseIso8601(str: []const u8) ?i64 { + const trimmed = std.mem.trim(u8, str, " \t\r\n"); + if (trimmed.len < 19) return null; // YYYY-MM-DDTHH:MM:SS + if (trimmed[4] != '-' or trimmed[7] != '-' or (trimmed[10] != 'T' and trimmed[10] != 't' and trimmed[10] != ' ')) return null; + if (trimmed[13] != ':' or trimmed[16] != ':') return null; + + const year = std.fmt.parseInt(i64, trimmed[0..4], 10) catch return null; + const month = std.fmt.parseInt(u8, trimmed[5..7], 10) catch return null; + const day = std.fmt.parseInt(u8, trimmed[8..10], 10) catch return null; + if (month < 1 or month > 12) return null; + if (day < 1 or day > 31) return null; + + const hour = std.fmt.parseInt(i64, trimmed[11..13], 10) catch return null; + const minute = std.fmt.parseInt(i64, trimmed[14..16], 10) catch return null; + const second = std.fmt.parseInt(i64, trimmed[17..19], 10) catch return null; + if (hour < 0 or hour > 23 or minute < 0 or minute > 59 or second < 0 or second > 60) return null; + + var idx: usize = 19; + var millis: i64 = 0; + if (idx < trimmed.len and trimmed[idx] == '.') { + idx += 1; + var frac_digits: usize = 0; + var frac_val: i64 = 0; + while (idx < trimmed.len and std.ascii.isDigit(trimmed[idx])) : (idx += 1) { + if (frac_digits < 3) { + frac_val = frac_val * 10 + (trimmed[idx] - '0'); + frac_digits += 1; + } + } + while (frac_digits < 3) : (frac_digits += 1) { + frac_val *= 10; + } + millis = frac_val; + } + + var offset_sec: i64 = 0; + if (idx < trimmed.len) { + if (trimmed[idx] == 'Z' or trimmed[idx] == 'z') { + idx += 1; + } else if (trimmed[idx] == '+' or trimmed[idx] == '-') { + const sign: i64 = if (trimmed[idx] == '+') 1 else -1; + idx += 1; + if (idx + 2 <= trimmed.len) { + const off_h = std.fmt.parseInt(i64, trimmed[idx .. idx + 2], 10) catch return null; + idx += 2; + var off_m: i64 = 0; + if (idx < trimmed.len and trimmed[idx] == ':') idx += 1; + if (idx + 2 <= trimmed.len) { + off_m = std.fmt.parseInt(i64, trimmed[idx .. idx + 2], 10) catch return null; + idx += 2; + } + offset_sec = sign * (off_h * 3600 + off_m * 60); + } else { + return null; + } + } else { + return null; + } + } + if (idx != trimmed.len) return null; // trailing unparsed garbage + + const days = daysFromCivil(year, month, day); + const total_sec = days * 86400 + hour * 3600 + minute * 60 + second - offset_sec; + return total_sec * 1000 + millis; +} + +test "parseIso8601 with various formats" { + const cases = [_]struct { s: []const u8, expected: i64 }{ + .{ .s = "2026-09-16T08:00:00.037593+00:00", .expected = 1789545600037 }, + .{ .s = "2026-09-15T23:46:00Z", .expected = 1789515960000 }, + .{ .s = "2026-06-09T11:28:39.481128Z", .expected = 1781004519481 }, + .{ .s = "2026-01-01T00:00:00+05:30", .expected = 1767205800000 }, + .{ .s = "2026-01-01T00:00:00-08:00", .expected = 1767254400000 }, + .{ .s = "2026-01-01T00:00:00.5Z", .expected = 1767225600500 }, + .{ .s = "2026-01-01T00:00:00.12Z", .expected = 1767225600120 }, + .{ .s = "2026-01-01T00:00:00.123Z", .expected = 1767225600123 }, + .{ .s = "2026-01-01T00:00:00.1234Z", .expected = 1767225600123 }, + }; + + for (cases) |c| { + const got = parseIso8601(c.s); + try std.testing.expectEqual(c.expected, got.?); + } + + try std.testing.expect(parseIso8601("") == null); + try std.testing.expect(parseIso8601("not-a-date") == null); + try std.testing.expect(parseIso8601("2026-09-16") == null); +} diff --git a/src/main.zig b/src/main.zig index d9b8674..f0435bd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,14 +1,13 @@ -//! Small CLI over the core: for debugging, and for parity tests against the TypeScript -//! reference. It reads one file it is handed and nothing else. +//! CLI over the core: for debugging, and for parity tests against the TypeScript +//! reference. const std = @import("std"); const usage = @import("usage.zig"); const help_text = \\usage - \\ providers providers with a usage feed - \\ parse [--now-ms N] parse a provider body into limits - \\ claims account id and email from an OAuth token - \\ request [account-id] + \\ providers supported providers info + \\ advance [--now-ms N] advance feed state + \\ claims account id and email from an OAuth token \\ ; @@ -31,14 +30,16 @@ fn fail(init: std.process.Init, message: []const u8) !u8 { return 1; } -/// The host always passes `now` through the ABI; this is only so a person can run the -/// CLI without knowing the epoch milliseconds. fn nowMs() i64 { var ts: std.posix.timespec = undefined; if (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts)) != .SUCCESS) return 0; return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); } +fn readFile(init: std.process.Init, path: []const u8, arena: std.mem.Allocator) ![]u8 { + return std.Io.Dir.cwd().readFileAlloc(init.io, path, arena, std.Io.Limit.limited(8 * 1024 * 1024)); +} + pub fn main(init: std.process.Init) !u8 { const arena = init.arena.allocator(); const argv = try std.process.Args.toSlice(init.minimal.args, arena); @@ -48,38 +49,42 @@ pub fn main(init: std.process.Init) !u8 { if (std.mem.eql(u8, command, "providers")) { var w = usage.Writer.init(arena); - try w.raw("["); - for (usage.supported_providers, 0..) |provider, i| { - if (i > 0) try w.raw(","); - try w.string(provider); - } - try w.raw("]"); + try usage.writeProviders(&w); try emit(init, w.items()); return 0; } - if (std.mem.eql(u8, command, "parse")) { - if (argv.len < 3) return fail(init, "parse needs a file"); + if (std.mem.eql(u8, command, "advance")) { + if (argv.len < 5) return fail(init, "advance needs "); + const provider = argv[2]; + const cred_arg = argv[3]; + const resp_arg = argv[4]; + var now_ms = nowMs(); - var i: usize = 3; + var i: usize = 5; 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; } } - const body = std.Io.Dir.cwd().readFileAlloc(init.io, argv[2], arena, std.Io.Limit.limited(8 * 1024 * 1024)) catch |e| { - return fail(init, @errorName(e)); - }; + + // Arguments could be raw JSON or file paths + 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 w = usage.Writer.init(arena); - const outcome = try usage.parse(arena, "openai-codex", body, now_ms, &w); - switch (outcome) { - .ok => { - try emit(init, w.items()); - return 0; - }, - .err => |text| return fail(init, text), - } + const outcome = try usage.advance(arena, provider, cred_str, resp_str, now_ms); + try usage.writeStep(&w, outcome); + try emit(init, w.items()); + return 0; } if (std.mem.eql(u8, command, "claims")) { @@ -95,19 +100,5 @@ pub fn main(init: std.process.Init) !u8 { } } - if (std.mem.eql(u8, command, "request")) { - if (argv.len < 4) return fail(init, "request needs a provider and an access token"); - const account: ?[]const u8 = if (argv.len > 4) argv[4] else null; - var w = usage.Writer.init(arena); - const outcome = try usage.buildRequest(argv[2], argv[3], account, &w); - switch (outcome) { - .ok => { - try emit(init, w.items()); - return 0; - }, - .err => |text| return fail(init, text), - } - } - return fail(init, "unknown command"); } diff --git a/src/providers/alibaba.zig b/src/providers/alibaba.zig new file mode 100644 index 0000000..98ca59a --- /dev/null +++ b/src/providers/alibaba.zig @@ -0,0 +1,368 @@ +//! Alibaba Model Studio usage feed implementation. +//! +//! Mirrors packages/ai/src/utils/usage/providers/alibaba.ts field for field. +const std = @import("std"); +const usage = @import("../usage.zig"); + +pub const ALIBABA_GATEWAY_URL = "https://bailian-singapore-cs.alibabacloud.com/data/api.json"; +pub const ALIBABA_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage"; +pub const ALIBABA_SUBSCRIPTION_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription"; +pub const ALIBABA_RESET_CARD_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/reset-card/list"; + +pub fn buildGatewayUrl(alloc: std.mem.Allocator, base_url: []const u8, api: []const u8) ![]const u8 { + const clean_base = if (std.mem.indexOfScalar(u8, base_url, '?')) |idx| base_url[0..idx] else base_url; + return try std.fmt.allocPrint(alloc, "{s}?action=IntlBroadScopeAspnGateway&product=sfm_bailian&api={s}&_v=", .{ clean_base, api }); +} + +fn unwrapEnvelope(value: ?std.json.Value) ?std.json.Value { + const root = usage.asObject(value) orelse return value; + const data = usage.asObject(root.get("data")); + if (data) |d| { + const data_v2 = usage.asObject(d.get("DataV2")); + if (data_v2) |v2| { + const v2_data = usage.asObject(v2.get("data")); + if (v2_data) |v2d| { + if (v2d.get("data")) |inner| return inner; + } + } + if (d.get("data")) |inner| return inner; + } + return value; +} + +fn extractErrorMessage(value: ?std.json.Value) ?[]const u8 { + const root = usage.asObject(value) orelse return null; + const data = usage.asObject(root.get("data")); + const data_v2 = if (data) |d| usage.asObject(d.get("DataV2")) else null; + const v2_data = if (data_v2) |v2| usage.asObject(v2.get("data")) else null; + + if (v2_data) |v2d| { + const success_val = v2d.get("success"); + const is_success = if (success_val) |sv| switch (sv) { + .bool => |b| b, + else => true, + } else true; + if (!is_success) { + if (usage.asString(v2d.get("msg"))) |m| { + const trimmed = std.mem.trim(u8, m, " \t\r\n"); + if (trimmed.len > 0) return trimmed; + } + } + } + + if (data) |d| { + if (usage.asString(d.get("errorMsg"))) |em| { + const trimmed = std.mem.trim(u8, em, " \t\r\n"); + if (trimmed.len > 0) return trimmed; + } + } + + if (usage.asString(root.get("message"))) |m| { + const trimmed = std.mem.trim(u8, m, " \t\r\n"); + if (trimmed.len > 0) return trimmed; + } + + return null; +} + +pub const ParseAlibabaResult = struct { + limits: []const usage.UsageLimit = &[_]usage.UsageLimit{}, + planType: ?[]const u8 = null, + instanceCode: ?[]const u8 = null, + notes: []const []const u8 = &[_][]const u8{}, +}; + +pub fn parseAlibabaUsage( + alloc: std.mem.Allocator, + usage_payload: ?std.json.Value, + subscription_payload: ?std.json.Value, + reset_card_payload: ?std.json.Value, +) !ParseAlibabaResult { + var notes = std.ArrayList([]const u8).empty; + var limits = std.ArrayList(usage.UsageLimit).empty; + + // Subscription parsing + const sub_unwrapped = unwrapEnvelope(subscription_payload); + const sub_data = usage.asObject(sub_unwrapped); + const plan_type = if (sub_data) |sd| usage.asString(sd.get("specCode")) else null; + const instance_code = if (sub_data) |sd| usage.asString(sd.get("instanceCode")) else null; + const remaining_days = if (sub_data) |sd| usage.asNumber(sd.get("remainingDays")) else null; + + if (remaining_days) |rd| { + const note = try std.fmt.allocPrint(alloc, "{d} days remaining", .{@as(i64, @intFromFloat(rd))}); + try notes.append(alloc, note); + } + + // Reset card parsing + const card_unwrapped = unwrapEnvelope(reset_card_payload); + const card_array = usage.asArray(card_unwrapped); + if (card_array) |ca| { + if (ca.items.len > 0) { + const count = ca.items.len; + const suffix: []const u8 = if (count == 1) "" else "s"; + const note = try std.fmt.allocPrint(alloc, "{d} reset card{s} available", .{ count, suffix }); + try notes.append(alloc, note); + } + } + + // Usage limits + const usage_unwrapped = unwrapEnvelope(usage_payload); + const usage_data = usage.asObject(usage_unwrapped); + if (usage_data) |ud| { + // Optional 5h session window + const session_frac = usage.asNumber(ud.get("per5HourPercentage")); + const session_reset = usage.asNumber(ud.get("per5HourResetTime")); + if (session_frac != null or session_reset != null) { + // Converted FROM fraction (0..1) to percent (0..100) + const session_used_pct: ?f64 = if (session_frac) |sf| sf * 100.0 else null; + // Already epoch milliseconds from per5HourResetTime (no conversion needed) + const session_reset_ms: ?i64 = if (session_reset) |sr| @intFromFloat(sr) else null; + + try limits.append(alloc, .{ + .label = "Session", + .usedPercent = session_used_pct, + .windowLabel = "5h window", + .resetsAt = session_reset_ms, + .status = usage.statusFor(session_used_pct), + }); + } + + // 7d weekly window + const weekly_frac = usage.asNumber(ud.get("per1WeekPercentage")); + const weekly_reset = usage.asNumber(ud.get("per1WeekResetTime")); + if (weekly_frac != null or weekly_reset != null) { + // Converted FROM fraction (0..1) to percent (0..100) + const weekly_used_pct: ?f64 = if (weekly_frac) |wf| wf * 100.0 else null; + // Already epoch milliseconds from per1WeekResetTime (no conversion needed) + const weekly_reset_ms: ?i64 = if (weekly_reset) |wr| @intFromFloat(wr) else null; + const detail: ?[]const u8 = if (remaining_days) |rd| + try std.fmt.allocPrint(alloc, "{d} days remaining", .{@as(i64, @intFromFloat(rd))}) + else + null; + + try limits.append(alloc, .{ + .label = "Weekly", + .usedPercent = weekly_used_pct, + .windowLabel = "7d window", + .resetsAt = weekly_reset_ms, + .status = usage.statusFor(weekly_used_pct), + .detail = detail, + }); + } + } + + return ParseAlibabaResult{ + .limits = limits.items, + .planType = plan_type, + .instanceCode = instance_code, + .notes = notes.items, + }; +} + +fn buildFormBody(alloc: std.mem.Allocator, params_json: []const u8, sec_token: []const u8, region: []const u8) ![]const u8 { + const enc_params = try usage.urlEncode(alloc, params_json); + const enc_token = try usage.urlEncode(alloc, sec_token); + const enc_region = try usage.urlEncode(alloc, region); + return try std.fmt.allocPrint(alloc, "params={s}&sec_token={s}®ion={s}", .{ enc_params, enc_token, enc_region }); +} + +pub fn advance( + alloc: std.mem.Allocator, + credential_json: []const u8, + responses_json: []const u8, + _now_ms: i64, +) !usage.Outcome { + _ = _now_ms; + const cred_parsed = std.json.parseFromSlice(std.json.Value, alloc, credential_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const cred_root = usage.asObject(cred_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const kind = usage.asString(cred_root.get("kind")) orelse ""; + if (!std.mem.eql(u8, kind, "session")) { + return .{ .report = .{ .error_message = "alibaba needs a session credential" } }; + } + + const session_val = cred_root.get("session"); + const session = usage.asObject(session_val) orelse { + return .{ .report = .{ .error_message = "alibaba session credential missing session object" } }; + }; + + const sec_token_raw = usage.asString(session.get("secToken")); + const sec_token = if (sec_token_raw) |st| std.mem.trim(u8, st, " \t\r\n") else ""; + if (sec_token.len == 0) { + return .{ .report = .{ .error_message = "alibaba session missing secToken" } }; + } + + const region_raw = usage.asString(session.get("region")); + const region = if (region_raw) |r| std.mem.trim(u8, r, " \t\r\n") else "ap-southeast-1"; + + const base_url_raw = usage.asString(session.get("url")); + const base_url = if (base_url_raw) |u| std.mem.trim(u8, u, " \t\r\n") else ALIBABA_GATEWAY_URL; + + const cred_account_id = usage.asString(cred_root.get("accountId")); + const cred_email = usage.asString(cred_root.get("email")); + + const resp_parsed = std.json.parseFromSlice(std.json.Value, alloc, responses_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + const resp_array = usage.asArray(resp_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + + if (resp_array.items.len == 0) { + var base_headers = std.ArrayList([2][]const u8).empty; + try base_headers.append(alloc, .{ "content-type", "application/x-www-form-urlencoded" }); + try base_headers.append(alloc, .{ "accept", "*/*" }); + try base_headers.append(alloc, .{ "origin", "https://modelstudio.console.alibabacloud.com" }); + const referer = try std.fmt.allocPrint(alloc, "https://modelstudio.console.alibabacloud.com/{s}/subscription/token-plan/personal", .{region}); + try base_headers.append(alloc, .{ "referer", referer }); + + const session_headers = usage.asObject(session.get("headers")); + if (session_headers) |sh| { + var it = sh.iterator(); + while (it.next()) |entry| { + if (usage.asString(entry.value_ptr.*)) |val| { + const key_lower = try alloc.alloc(u8, entry.key_ptr.*.len); + _ = std.ascii.lowerString(key_lower, entry.key_ptr.*); + if (!std.mem.eql(u8, key_lower, "content-type")) { + try base_headers.append(alloc, .{ key_lower, val }); + } + } + } + } + + const usage_json = + \\{"Api":"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage","V":"1.0","Data":{"cornerstoneParam":{"protocol":"V2","console":"ONE_CONSOLE","productCode":"p_efm","domain":"modelstudio.console.alibabacloud.com","consoleSite":"MODELSTUDIO_ALBABACLOUD","xsp_lang":"en-US"}}} + ; + const sub_json = + \\{"Api":"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription","V":"1.0","Data":{"queryInstanceInfoRequest":{"commodityCode":"sfm_tokenplansolo_public_intl"},"cornerstoneParam":{"protocol":"V2","console":"ONE_CONSOLE","productCode":"p_efm","domain":"modelstudio.console.alibabacloud.com","consoleSite":"MODELSTUDIO_ALBABACLOUD","xsp_lang":"en-US"}}} + ; + const card_json = + \\{"Api":"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/reset-card/list","V":"1.0","Data":{"cornerstoneParam":{"protocol":"V2","console":"ONE_CONSOLE","productCode":"p_efm","domain":"modelstudio.console.alibabacloud.com","consoleSite":"MODELSTUDIO_ALBABACLOUD","xsp_lang":"en-US"}}} + ; + + const usage_body = try buildFormBody(alloc, usage_json, sec_token, region); + const sub_body = try buildFormBody(alloc, sub_json, sec_token, region); + const card_body = try buildFormBody(alloc, card_json, sec_token, region); + + const requests = try alloc.alloc(usage.UsageRequest, 3); + requests[0] = .{ + .method = "POST", + .url = try buildGatewayUrl(alloc, base_url, ALIBABA_USAGE_API), + .headers = base_headers.items, + .body = usage_body, + }; + requests[1] = .{ + .method = "POST", + .url = try buildGatewayUrl(alloc, base_url, ALIBABA_SUBSCRIPTION_API), + .headers = base_headers.items, + .body = sub_body, + }; + requests[2] = .{ + .method = "POST", + .url = try buildGatewayUrl(alloc, base_url, ALIBABA_RESET_CARD_API), + .headers = base_headers.items, + .body = card_body, + }; + return .{ .requests = requests }; + } + + // Match responses by api field or fallback to positional + var usage_resp: ?std.json.ObjectMap = null; + var sub_resp: ?std.json.ObjectMap = null; + var card_resp: ?std.json.ObjectMap = null; + + for (resp_array.items) |r_val| { + const r_obj = usage.asObject(r_val) orelse continue; + const b_str = usage.asString(r_obj.get("body")) orelse continue; + if (std.json.parseFromSlice(std.json.Value, alloc, b_str, .{})) |parsed| { + if (usage.asObject(parsed.value)) |root| { + const data_obj = usage.asObject(root.get("data")); + const api = if (data_obj) |d| usage.asString(d.get("api")) else usage.asString(root.get("api")); + if (api) |a| { + if (std.mem.eql(u8, a, ALIBABA_USAGE_API)) { + usage_resp = r_obj; + } else if (std.mem.eql(u8, a, ALIBABA_SUBSCRIPTION_API)) { + sub_resp = r_obj; + } else if (std.mem.eql(u8, a, ALIBABA_RESET_CARD_API)) { + card_resp = r_obj; + } + } + } + } else |_| {} + } + + if (usage_resp == null and resp_array.items.len > 0) { + usage_resp = usage.asObject(resp_array.items[0]); + } + if (sub_resp == null and resp_array.items.len > 1) { + sub_resp = usage.asObject(resp_array.items[1]); + } + if (card_resp == null and resp_array.items.len > 2) { + card_resp = usage.asObject(resp_array.items[2]); + } + + if (usage_resp == null) { + return .{ .report = .{ .error_message = "HTTP no response" } }; + } + const status = usage.asNumber(usage_resp.?.get("status")); + 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 usage_b_str = usage.asString(usage_resp.?.get("body")); + if (usage_b_str == null) { + return .{ .report = .{ .error_message = "usage body is not JSON" } }; + } + const usage_b_parsed = std.json.parseFromSlice(std.json.Value, alloc, usage_b_str.?, .{}) catch { + return .{ .report = .{ .error_message = "usage body is not JSON" } }; + }; + + if (extractErrorMessage(usage_b_parsed.value)) |msg| { + return .{ .report = .{ .error_message = msg } }; + } + + var sub_json_val: ?std.json.Value = null; + if (sub_resp) |sr| { + const st = usage.asNumber(sr.get("status")); + if (st != null and st.? == 200.0) { + if (usage.asString(sr.get("body"))) |sb_str| { + if (std.json.parseFromSlice(std.json.Value, alloc, sb_str, .{})) |sb_parsed| { + sub_json_val = sb_parsed.value; + } else |_| {} + } + } + } + + var card_json_val: ?std.json.Value = null; + if (card_resp) |cr| { + const st = usage.asNumber(cr.get("status")); + if (st != null and st.? == 200.0) { + if (usage.asString(cr.get("body"))) |cb_str| { + if (std.json.parseFromSlice(std.json.Value, alloc, cb_str, .{})) |cb_parsed| { + card_json_val = cb_parsed.value; + } else |_| {} + } + } + } + + const parsed = try parseAlibabaUsage(alloc, usage_b_parsed.value, sub_json_val, card_json_val); + const account_id = cred_account_id orelse parsed.instanceCode; + + return .{ + .report = .{ + .planType = parsed.planType, + .accountId = account_id, + .email = cred_email, + .limits = parsed.limits, + .notes = parsed.notes, + .error_message = null, + }, + }; +} diff --git a/src/providers/anthropic.zig b/src/providers/anthropic.zig new file mode 100644 index 0000000..d193846 --- /dev/null +++ b/src/providers/anthropic.zig @@ -0,0 +1,275 @@ +//! Anthropic usage feed implementation. +//! +//! Mirrors packages/ai/src/utils/usage/providers/anthropic.ts field for field. +const std = @import("std"); +const usage = @import("../usage.zig"); +const iso8601 = @import("../iso8601.zig"); + +pub const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; +pub const ANTHROPIC_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"; +const OAUTH_BETA = "oauth-2025-04-20"; + +pub const WindowInfo = struct { + label: []const u8, + windowLabel: ?[]const u8 = null, +}; + +fn titleCase(alloc: std.mem.Allocator, value: []const u8) ![]const u8 { + var parts = std.mem.splitAny(u8, value, "_-"); + var res = std.ArrayList(u8).empty; + var first = true; + while (parts.next()) |part| { + if (part.len == 0) continue; + if (!first) try res.append(alloc, ' '); + first = false; + try res.append(alloc, std.ascii.toUpper(part[0])); + if (part.len > 1) { + try res.appendSlice(alloc, part[1..]); + } + } + return res.items; +} + +pub fn windowForKind(alloc: std.mem.Allocator, kind: []const u8) !WindowInfo { + if (std.mem.eql(u8, kind, "session") or std.mem.endsWith(u8, kind, "_session")) { + return .{ .label = "Session", .windowLabel = "5h window" }; + } + 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" }; + } + const tc = try titleCase(alloc, scope); + const label = try std.fmt.allocPrint(alloc, "Weekly ({s})", .{tc}); + return .{ .label = label, .windowLabel = "7d window" }; + } + if (std.mem.eql(u8, kind, "weekly")) { + return .{ .label = "Weekly", .windowLabel = "7d window" }; + } + 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" }; + } + const label = try titleCase(alloc, kind); + return .{ .label = label, .windowLabel = null }; +} + +pub const ParseUsageResult = struct { + limits: []const usage.UsageLimit, + notes: []const []const u8, +}; + +pub fn parseAnthropicUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, _now_ms: i64) !ParseUsageResult { + _ = _now_ms; + const root = usage.asObject(payload); + var notes = std.ArrayList([]const u8).empty; + var limits = std.ArrayList(usage.UsageLimit).empty; + + const entries = if (root) |r| usage.asArray(r.get("limits")) else null; + if (entries) |arr| { + for (arr.items) |entry| { + const limit = usage.asObject(entry) orelse continue; + const kind = usage.asString(limit.get("kind")) orelse "unknown"; + const used_percent = usage.asNumber(limit.get("percent")); + const win = try windowForKind(alloc, kind); + const severity = usage.asString(limit.get("severity")); + + const status = if (used_percent == null) + "unknown" + else if (severity != null and std.mem.eql(u8, severity.?, "critical")) + "warning" + else + usage.statusFor(used_percent); + + const final_status = if (used_percent != null and used_percent.? >= 100.0) + "exhausted" + else + status; + + const resets_at_str = usage.asString(limit.get("resets_at")); + // 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, .{ + .label = win.label, + .usedPercent = used_percent, + .windowLabel = win.windowLabel, + .resetsAt = resets_at_ms, + .status = final_status, + }); + } + } + + const extra = if (root) |r| usage.asObject(r.get("extra_usage")) else null; + if (extra) |ex| { + const is_enabled = ex.get("is_enabled"); + const enabled_bool = if (is_enabled) |b| switch (b) { + .bool => |v| v, + else => false, + } else false; + + if (enabled_bool) { + const monthly_limit = usage.asNumber(ex.get("monthly_limit")); + const used_credits = usage.asNumber(ex.get("used_credits")); + // 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; + + try limits.append(alloc, .{ + .label = "Extra usage", + .usedPercent = percent, + .resetsAt = null, + .status = usage.statusFor(percent), + .detail = detail, + }); + } else { + const credits_ever_enabled = ex.get("credits_ever_enabled"); + const ever_bool = if (credits_ever_enabled) |b| switch (b) { + .bool => |v| v, + else => false, + } else false; + if (ever_bool) { + try notes.append(alloc, "Extra usage is off"); + } + } + } + + return ParseUsageResult{ + .limits = limits.items, + .notes = notes.items, + }; +} + +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, + credential_json: []const u8, + responses_json: []const u8, + now_ms: i64, +) !usage.Outcome { + const cred_parsed = std.json.parseFromSlice(std.json.Value, alloc, credential_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const cred_root = usage.asObject(cred_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const kind = usage.asString(cred_root.get("kind")) orelse ""; + if (!std.mem.eql(u8, kind, "oauth")) { + return .{ .report = .{ .error_message = "anthropic needs an OAuth account" } }; + } + const access = usage.asString(cred_root.get("access")) orelse ""; + const cred_email = stringOf(cred_root.get("email")); + const cred_account_id = stringOf(cred_root.get("accountId")); + + const resp_parsed = std.json.parseFromSlice(std.json.Value, alloc, responses_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + const resp_array = usage.asArray(resp_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + + if (resp_array.items.len == 0) { + var headers = std.ArrayList([2][]const u8).empty; + const auth_hdr = try std.fmt.allocPrint(alloc, "Bearer {s}", .{access}); + try headers.append(alloc, .{ "Authorization", auth_hdr }); + try headers.append(alloc, .{ "anthropic-beta", OAUTH_BETA }); + try headers.append(alloc, .{ "User-Agent", "prime-agent" }); + + const requests = try alloc.alloc(usage.UsageRequest, 2); + requests[0] = .{ + .method = "GET", + .url = ANTHROPIC_USAGE_URL, + .headers = headers.items, + }; + requests[1] = .{ + .method = "GET", + .url = ANTHROPIC_PROFILE_URL, + .headers = headers.items, + }; + return .{ .requests = requests }; + } + + const 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" } }; + }; + + // Profile response (optional / responses[1]) + var profile_obj: ?std.json.ObjectMap = null; + if (resp_array.items.len > 1) { + const resp1_obj = usage.asObject(resp_array.items[1]); + if (resp1_obj) |r1| { + if (usage.asString(r1.get("body"))) |pbody| { + if (std.json.parseFromSlice(std.json.Value, alloc, pbody, .{})) |p_parsed| { + profile_obj = usage.asObject(p_parsed.value); + } else |_| {} + } + } + } + + const org_obj = if (profile_obj) |p| usage.asObject(p.get("organization")) else null; + const acct_obj = if (profile_obj) |p| usage.asObject(p.get("account")) else null; + + const parsed_usage = try parseAnthropicUsage(alloc, body_parsed.value, now_ms); + + const plan_type = if (org_obj) |o| + (stringOf(o.get("seat_tier")) orelse stringOf(o.get("rate_limit_tier"))) + else + null; + + const account_email = if (acct_obj) |a| stringOf(a.get("email")) else null; + const final_email: ?[]const u8 = if (account_email) |em| blk: { + const lowered = try alloc.alloc(u8, em.len); + break :blk std.ascii.lowerString(lowered, em); + } else cred_email; + + const final_account_id = if (cred_account_id) |cid| + cid + else if (acct_obj) |a| + stringOf(a.get("uuid")) + else + null; + + return .{ + .report = .{ + .planType = plan_type, + .email = final_email, + .accountId = final_account_id, + .limits = parsed_usage.limits, + .notes = parsed_usage.notes, + .error_message = null, + }, + }; +} diff --git a/src/providers/antigravity.zig b/src/providers/antigravity.zig new file mode 100644 index 0000000..3b3c4fa --- /dev/null +++ b/src/providers/antigravity.zig @@ -0,0 +1,193 @@ +//! Google Antigravity usage feed implementation. +//! +//! Mirrors 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"); + +pub const ANTIGRAVITY_USAGE_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota"; +pub const ANTIGRAVITY_USER_AGENT = "antigravity/hub/2.8.0 (aidev_client; os_type=darwin; arch=arm64; cl=963137146)"; + +const FamilyInfo = struct { + min_remaining: ?f64 = null, + reset_time: ?[]const u8 = null, + present: bool = false, +}; + +fn modelFamily(alloc: std.mem.Allocator, model_id: []const u8) !?[]const u8 { + const lower = try alloc.alloc(u8, model_id.len); + _ = std.ascii.lowerString(lower, model_id); + if (std.mem.startsWith(u8, lower, "chat_") or std.mem.startsWith(u8, lower, "tab_")) { + return null; + } + if (std.mem.indexOf(u8, lower, "gemini") != null) return "Gemini"; + if (std.mem.indexOf(u8, lower, "claude") != null) return "Claude"; + if (std.mem.indexOf(u8, lower, "gpt") != null) return "GPT-OSS"; + return null; +} + +pub fn parseAntigravityUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, now_ms: i64) ![]const usage.UsageLimit { + const root = usage.asObject(payload); + const buckets = if (root) |r| usage.asArray(r.get("buckets")) else null; + + var gemini = FamilyInfo{}; + var claude = FamilyInfo{}; + var gpt = FamilyInfo{}; + + if (buckets) |arr| { + for (arr.items) |entry| { + const bucket = usage.asObject(entry) orelse continue; + 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")); + + const target: *FamilyInfo = if (std.mem.eql(u8, family, "Gemini")) + &gemini + else if (std.mem.eql(u8, family, "Claude")) + &claude + else + &gpt; + + if (!target.present) { + target.present = true; + target.min_remaining = remaining_fraction; + target.reset_time = reset_time; + } else { + if (remaining_fraction != null and (target.min_remaining == null or remaining_fraction.? < target.min_remaining.?)) { + target.min_remaining = remaining_fraction; + target.reset_time = reset_time; + } else if (target.reset_time == null and reset_time != null) { + target.reset_time = reset_time; + } + } + } + } + + 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 }, + }; + + for (families) |f| { + 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 status = if (used_percent) |up| usage.statusFor(up) else "unknown"; + + try limits.append(alloc, .{ + .label = f.label, + .usedPercent = used_percent, + .windowLabel = window_label, + .resetsAt = resets_at, + .detail = detail, + .status = status, + }); + } + + return limits.items; +} + +pub fn advance( + alloc: std.mem.Allocator, + credential_json: []const u8, + responses_json: []const u8, + now_ms: i64, +) !usage.Outcome { + const cred_parsed = std.json.parseFromSlice(std.json.Value, alloc, credential_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const cred_root = usage.asObject(cred_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const kind = usage.asString(cred_root.get("kind")) orelse ""; + if (!std.mem.eql(u8, kind, "oauth")) { + return .{ .report = .{ .error_message = "google-antigravity needs an OAuth account" } }; + } + + const access = usage.asString(cred_root.get("access")) orelse ""; + const extra_obj = usage.asObject(cred_root.get("extra")); + const project_id = if (extra_obj) |eo| usage.asString(eo.get("projectId")) else null; + const cred_account_id = usage.asString(cred_root.get("accountId")); + const account_id = cred_account_id orelse project_id; + const email = usage.asString(cred_root.get("email")); + + const resp_parsed = std.json.parseFromSlice(std.json.Value, alloc, responses_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + const resp_array = usage.asArray(resp_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + + if (resp_array.items.len == 0) { + var headers = std.ArrayList([2][]const u8).empty; + const auth_hdr = try std.fmt.allocPrint(alloc, "Bearer {s}", .{access}); + try headers.append(alloc, .{ "Authorization", auth_hdr }); + try headers.append(alloc, .{ "Content-Type", "application/json" }); + try headers.append(alloc, .{ "User-Agent", ANTIGRAVITY_USER_AGENT }); + + const requests = try alloc.alloc(usage.UsageRequest, 1); + requests[0] = .{ + .method = "POST", + .url = ANTIGRAVITY_USAGE_URL, + .headers = headers.items, + .body = "{}", + }; + 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 limits = try parseAntigravityUsage(alloc, body_parsed.value, now_ms); + + const notes: []const []const u8 = if (project_id) |pid| blk: { + const arr = try alloc.alloc([]const u8, 1); + arr[0] = try std.fmt.allocPrint(alloc, "Project: {s}", .{pid}); + break :blk arr; + } else &[_][]const u8{}; + + return .{ + .report = .{ + .planType = "Cloud Code", + .accountId = account_id, + .email = email, + .limits = limits, + .notes = notes, + .error_message = null, + }, + }; +} diff --git a/src/providers/codex.zig b/src/providers/codex.zig new file mode 100644 index 0000000..36a3fc7 --- /dev/null +++ b/src/providers/codex.zig @@ -0,0 +1,193 @@ +//! OpenAI Codex usage feed implementation. +//! +//! Mirrors packages/ai/src/utils/usage/providers/codex.ts field for field. +const std = @import("std"); +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 { + 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")); + 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 + null; + + const window_lbl = try usage.formatWindowLabel(alloc, limit_window); + + return usage.UsageLimit{ + .label = "Usage", + .usedPercent = used_percent, + .windowLabel = window_lbl, + .resetsAt = reset_ms, + .status = usage.statusFor(used_percent), + }; +} + +pub const ParseResult = struct { + planType: ?[]const u8 = null, + limits: []const usage.UsageLimit = &[_]usage.UsageLimit{}, +}; + +pub fn parseCodexUsage(alloc: std.mem.Allocator, payload: ?std.json.Value, now_ms: i64) !ParseResult { + const root = usage.asObject(payload); + const rate_limit = if (root) |r| usage.asObject(r.get("rate_limit")) else null; + 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| { + 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| { + var w = sw; + w.label = "Secondary"; + try limits.append(alloc, w); + } + + const additional = if (root) |r| usage.asArray(r.get("additional_rate_limits")) else null; + if (additional) |arr| { + 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 extra_primary = if (extra) |e| e.get("primary_window") else null; + if (try parseWindow(alloc, extra_primary, now_ms)) |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| { + var w = es; + w.label = try std.fmt.allocPrint(alloc, "{s} · Secondary", .{name}); + try limits.append(alloc, w); + } + } + } + + const plan_type = if (root) |r| usage.asString(r.get("plan_type")) else null; + return ParseResult{ + .planType = plan_type, + .limits = limits.items, + }; +} + +fn jwtClaim(alloc: std.mem.Allocator, token: []const u8, namespace: []const u8, key: []const u8) ?[]const u8 { + const payload = usage.jwtPayload(alloc, token) catch return null; + const parsed = std.json.parseFromSlice(std.json.Value, alloc, payload, .{}) catch return null; + const root = usage.asObject(parsed.value) orelse return null; + const ns = usage.asObject(root.get(namespace)) orelse return null; + return usage.asString(ns.get(key)); +} + +fn jwtClaimEmail(alloc: std.mem.Allocator, token: []const u8) ?[]const u8 { + const email_raw = jwtClaim(alloc, token, "https://api.openai.com/profile", "email") orelse return null; + const trimmed = std.mem.trim(u8, email_raw, " \t\r\n"); + if (trimmed.len == 0) return null; + const lowered = alloc.alloc(u8, trimmed.len) catch return null; + return std.ascii.lowerString(lowered, trimmed); +} + +pub fn advance( + alloc: std.mem.Allocator, + credential_json: []const u8, + responses_json: []const u8, + now_ms: i64, +) !usage.Outcome { + const cred_parsed = std.json.parseFromSlice(std.json.Value, alloc, credential_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const cred_root = usage.asObject(cred_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const kind = usage.asString(cred_root.get("kind")) orelse ""; + if (!std.mem.eql(u8, kind, "oauth")) { + return .{ .report = .{ .error_message = "openai-codex needs an OAuth account" } }; + } + + const access = usage.asString(cred_root.get("access")) orelse ""; + const cred_account_id = usage.asString(cred_root.get("accountId")); + const account_id = if (cred_account_id != null and cred_account_id.?.len > 0) + cred_account_id + else + jwtClaim(alloc, access, "https://api.openai.com/auth", "chatgpt_account_id"); + + const cred_email = usage.asString(cred_root.get("email")); + const email = if (cred_email != null and cred_email.?.len > 0) + cred_email + else + jwtClaimEmail(alloc, access); + + const resp_parsed = std.json.parseFromSlice(std.json.Value, alloc, responses_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + const resp_array = usage.asArray(resp_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + + if (resp_array.items.len == 0) { + var headers = std.ArrayList([2][]const u8).empty; + const auth_hdr = try std.fmt.allocPrint(alloc, "Bearer {s}", .{access}); + try headers.append(alloc, .{ "Authorization", auth_hdr }); + try headers.append(alloc, .{ "User-Agent", "prime-agent" }); + if (account_id) |aid| { + if (aid.len > 0) { + try headers.append(alloc, .{ "ChatGPT-Account-Id", aid }); + } + } + const requests = try alloc.alloc(usage.UsageRequest, 1); + requests[0] = .{ + .method = "GET", + .url = CODEX_USAGE_URL, + .headers = headers.items, + }; + 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 parsed_usage = try parseCodexUsage(alloc, body_parsed.value, now_ms); + + return .{ + .report = .{ + .planType = parsed_usage.planType, + .limits = parsed_usage.limits, + .accountId = account_id, + .email = email, + .error_message = null, + }, + }; +} diff --git a/src/providers/deepseek.zig b/src/providers/deepseek.zig new file mode 100644 index 0000000..13cb6bf --- /dev/null +++ b/src/providers/deepseek.zig @@ -0,0 +1,140 @@ +//! DeepSeek usage feed implementation. +//! +//! Mirrors packages/ai/src/utils/usage/providers/deepseek.ts field for field. +const std = @import("std"); +const usage = @import("../usage.zig"); + +pub const DEEPSEEK_USAGE_URL = "https://api.deepseek.com/user/balance"; + +pub const ParseDeepSeekResult = struct { + planType: ?[]const u8 = null, + limits: []const usage.UsageLimit = &[_]usage.UsageLimit{}, + balance: ?[]const u8 = null, + notes: []const []const u8 = &[_][]const u8{}, +}; + +pub fn parseDeepSeekUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !ParseDeepSeekResult { + const root = usage.asObject(payload); + const infos = if (root) |r| usage.asArray(r.get("balance_infos")) else null; + if (infos == null or infos.?.items.len == 0) return ParseDeepSeekResult{}; + + 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 + else if (usage.asNumber(total_val)) |n| + try std.fmt.allocPrint(alloc, "{d:.2}", .{n}) + else + "0.00"; + + const curr_raw = usage.asString(first.get("currency")) orelse "USD"; + const currency = blk: { + const trimmed = std.mem.trim(u8, curr_raw, " \t\r\n"); + if (trimmed.len == 0) break :blk "USD"; + const upper = try alloc.alloc(u8, trimmed.len); + 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 }); + + var notes = std.ArrayList([]const u8).empty; + const is_available = if (root) |r| r.get("is_available") else null; + if (is_available) |ia| { + switch (ia) { + .bool => |b| if (!b) try notes.append(alloc, "Account is unavailable"), + else => {}, + } + } + + return ParseDeepSeekResult{ + .planType = "Pay-as-you-go", + .limits = &[_]usage.UsageLimit{}, + .balance = balance, + .notes = notes.items, + }; +} + +pub fn advance( + alloc: std.mem.Allocator, + credential_json: []const u8, + responses_json: []const u8, + _now_ms: i64, +) !usage.Outcome { + _ = _now_ms; + const cred_parsed = std.json.parseFromSlice(std.json.Value, alloc, credential_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const cred_root = usage.asObject(cred_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const kind = usage.asString(cred_root.get("kind")) orelse ""; + if (!std.mem.eql(u8, kind, "apiKey")) { + return .{ .report = .{ .error_message = "deepseek needs an apiKey credential" } }; + } + + const api_key = usage.asString(cred_root.get("apiKey")) orelse ""; + const cred_account_id = usage.asString(cred_root.get("accountId")); + const cred_email = usage.asString(cred_root.get("email")); + + const resp_parsed = std.json.parseFromSlice(std.json.Value, alloc, responses_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + const resp_array = usage.asArray(resp_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + + if (resp_array.items.len == 0) { + var headers = std.ArrayList([2][]const u8).empty; + const auth_hdr = try std.fmt.allocPrint(alloc, "Bearer {s}", .{api_key}); + try headers.append(alloc, .{ "Authorization", auth_hdr }); + try headers.append(alloc, .{ "Accept", "application/json" }); + + const requests = try alloc.alloc(usage.UsageRequest, 1); + requests[0] = .{ + .method = "GET", + .url = DEEPSEEK_USAGE_URL, + .headers = headers.items, + }; + 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 parsed = try parseDeepSeekUsage(alloc, body_parsed.value); + if (parsed.balance == null) { + return .{ .report = .{ .error_message = "response missing balance_infos" } }; + } + + return .{ + .report = .{ + .planType = parsed.planType, + .accountId = cred_account_id, + .email = cred_email, + .limits = parsed.limits, + .balance = parsed.balance, + .notes = parsed.notes, + .error_message = null, + }, + }; +} diff --git a/src/providers/hyper.zig b/src/providers/hyper.zig new file mode 100644 index 0000000..708490a --- /dev/null +++ b/src/providers/hyper.zig @@ -0,0 +1,314 @@ +//! Charm Hyper usage feed implementation. +//! +//! Mirrors 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{}, + balance: ?[]const u8 = null, +}; + +pub fn parseHyperBilling( + alloc: std.mem.Allocator, + payload: ?std.json.Value, + now_ms: i64, +) !ParseBillingResult { + const root = usage.asObject(payload); + const plan = if (root) |r| usage.asObject(r.get("plan")) else null; + const balances = if (root) |r| usage.asObject(r.get("balances")) else null; + const plan_balances = if (balances) |b| usage.asObject(b.get("plan")) else null; + const total_balances = if (balances) |b| usage.asObject(b.get("total")) else null; + const refresh = if (root) |r| usage.asObject(r.get("hypercredit_refresh")) else null; + + const plan_name = if (plan) |p| usage.asString(p.get("name")) else null; + const plan_type_raw = if (plan) |p| usage.asString(p.get("type")) else null; + + const plan_type: ?[]const u8 = if (plan_name) |pn| blk: { + const trimmed = std.mem.trim(u8, pn, " \t\r\n"); + if (trimmed.len == 0) break :blk null; + const lower = try alloc.alloc(u8, trimmed.len); + break :blk std.ascii.lowerString(lower, trimmed); + } else if (plan_type_raw) |ptr| blk: { + const trimmed = std.mem.trim(u8, ptr, " \t\r\n"); + if (trimmed.len == 0) break :blk null; + const lower = try alloc.alloc(u8, trimmed.len); + break :blk std.ascii.lowerString(lower, trimmed); + } else null; + + // 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}); + } + } else { + window_label = try usage.epochToWindowLabel(alloc, resets_at, now_ms); + } + + const root_status = if (root) |r| usage.asString(r.get("status")) else null; + const status: []const u8 = if (root_status == null or !std.mem.eql(u8, root_status.?, "active")) + "unknown" + else if (used_percent == null) + "unknown" + else if (used_percent.? >= 100.0) + "exhausted" + else if (used_percent.? >= 90.0) + "warning" + else + "ok"; + + const detail: ?[]const u8 = if (remaining != null and limit != null) + try std.fmt.allocPrint(alloc, "{d} of {d} credits", .{ + @as(i64, @intFromFloat(remaining.?)), + @as(i64, @intFromFloat(limit.?)), + }) + else + null; + + var limits = std.ArrayList(usage.UsageLimit).empty; + try limits.append(alloc, .{ + .label = "Primary", + .usedPercent = used_percent, + .windowLabel = window_label, + .resetsAt = resets_at, + .status = status, + .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"); + if (trimmed.len > 0) break :blk trimmed; + } + if (usage.asNumber(tb.get("usd"))) |n| { + break :blk try std.fmt.allocPrint(alloc, "{d:.2}", .{n}); + } + break :blk null; + } else null; + + return ParseBillingResult{ + .planType = plan_type, + .limits = limits.items, + .balance = balance, + }; +} + +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, + credential_json: []const u8, + responses_json: []const u8, + now_ms: i64, +) !usage.Outcome { + const cred_parsed = std.json.parseFromSlice(std.json.Value, alloc, credential_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const cred_root = usage.asObject(cred_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const kind = usage.asString(cred_root.get("kind")) orelse ""; + const token = resolveToken(cred_root, kind); + if (token == null) { + return .{ .report = .{ .error_message = "hyper needs an OAuth or apiKey credential" } }; + } + + const cred_account_id = usage.asString(cred_root.get("accountId")); + const cred_email = usage.asString(cred_root.get("email")); + + const resp_parsed = std.json.parseFromSlice(std.json.Value, alloc, responses_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + const resp_array = usage.asArray(resp_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + + var auth_hdr_list = std.ArrayList([2][]const u8).empty; + const auth_val = try std.fmt.allocPrint(alloc, "Bearer {s}", .{token.?}); + try auth_hdr_list.append(alloc, .{ "Authorization", auth_val }); + + if (resp_array.items.len == 0) { + const requests = try alloc.alloc(usage.UsageRequest, 1); + requests[0] = .{ + .method = "GET", + .url = HYPER_TEAMS_URL, + .headers = auth_hdr_list.items, + }; + return .{ .requests = requests }; + } + + 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); + + if (!is_billing_response and resp_array.items.len == 1) { + // Teams response + const items = usage.asArray(body0_obj.get("items")); + if (items == null or items.?.items.len == 0) { + return .{ .report = .{ .error_message = "no teams found for hyper account" } }; + } + + var selected_team: ?std.json.ObjectMap = null; + if (cred_account_id) |caid| { + for (items.?.items) |it| { + const o = usage.asObject(it) orelse continue; + if (usage.asString(o.get("id"))) |id| { + if (std.mem.eql(u8, id, caid)) { + selected_team = o; + break; + } + } + } + } + if (selected_team == null) { + selected_team = usage.asObject(items.?.items[0]); + } + + const team_id = if (selected_team) |st| usage.asString(st.get("id")) else null; + const team_name = if (selected_team) |st| usage.asString(st.get("name")) else null; + + if (team_id == null or team_id.?.len == 0) { + return .{ .report = .{ .error_message = "invalid team in hyper response" } }; + } + + cached_team = .{ + .team_id = try alloc.dupe(u8, team_id.?), + .team_name = if (team_name) |tn| try alloc.dupe(u8, tn) else null, + }; + + const billing_url = try std.fmt.allocPrint(alloc, "{s}/{s}/billing", .{ HYPER_TEAMS_URL, team_id.? }); + const members_url = try std.fmt.allocPrint(alloc, "{s}/{s}/members", .{ HYPER_TEAMS_URL, team_id.? }); + + const requests = try alloc.alloc(usage.UsageRequest, 2); + requests[0] = .{ + .method = "GET", + .url = billing_url, + .headers = auth_hdr_list.items, + }; + requests[1] = .{ + .method = "GET", + .url = members_url, + .headers = auth_hdr_list.items, + }; + return .{ .requests = requests }; + } + + // Billing response handling + const parsed_billing = try parseHyperBilling(alloc, body0_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) { + if (usage.asString(r1.get("body"))) |b1_str| { + if (std.json.parseFromSlice(std.json.Value, alloc, b1_str, .{})) |b1_parsed| { + if (usage.asObject(b1_parsed.value)) |b1_obj| { + if (usage.asArray(b1_obj.get("items"))) |mitems| { + if (mitems.items.len > 0) { + if (usage.asObject(mitems.items[0])) |m0| { + if (usage.asString(m0.get("email"))) |em| { + const trimmed = std.mem.trim(u8, em, " \t\r\n"); + if (trimmed.len > 0) member_email = trimmed; + } + } + } + } + } + } else |_| {} + } + } + } + } + + const account_id = cred_account_id orelse (if (cached_team) |ct| ct.team_id else null); + 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{}; + } else &[_][]const u8{}; + + return .{ + .report = .{ + .planType = parsed_billing.planType, + .accountId = account_id, + .email = email, + .limits = parsed_billing.limits, + .balance = parsed_billing.balance, + .notes = notes, + .error_message = null, + }, + }; +} diff --git a/src/providers/opencode_go.zig b/src/providers/opencode_go.zig new file mode 100644 index 0000000..1cff084 --- /dev/null +++ b/src/providers/opencode_go.zig @@ -0,0 +1,139 @@ +//! OpenCode Go usage feed implementation. +//! +//! Mirrors 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"); + +pub const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"; + +const WindowDef = struct { + key: []const u8, + label: []const u8, + windowLabel: []const u8, +}; + +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" }, +}; + +pub fn parseOpenCodeGoUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) ![]const usage.UsageLimit { + const root = usage.asObject(payload); + const usage_obj = if (root) |r| usage.asObject(r.get("usage")) else null; + if (usage_obj == null) return &[_]usage.UsageLimit{}; + + var limits = std.ArrayList(usage.UsageLimit).empty; + + 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; + + const raw_status = usage.asString(raw_window.get("status")); + const is_rate_limited = if (raw_status) |rs| blk: { + const lower = try alloc.alloc(u8, rs.len); + _ = std.ascii.lowerString(lower, rs); + break :blk std.mem.eql(u8, lower, "rate-limited") or std.mem.eql(u8, lower, "exhausted"); + } else false; + + const status: []const u8 = if (is_rate_limited or (used_percent != null and used_percent.? >= 100.0)) + "exhausted" + else + usage.statusFor(used_percent); + + try limits.append(alloc, .{ + .label = win.label, + .usedPercent = used_percent, + .windowLabel = win.windowLabel, + .resetsAt = resets_at, + .status = status, + }); + } + + return limits.items; +} + +pub fn advance( + alloc: std.mem.Allocator, + credential_json: []const u8, + responses_json: []const u8, + _now_ms: i64, +) !usage.Outcome { + _ = _now_ms; + const cred_parsed = std.json.parseFromSlice(std.json.Value, alloc, credential_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const cred_root = usage.asObject(cred_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const kind = usage.asString(cred_root.get("kind")) orelse ""; + if (!std.mem.eql(u8, kind, "apiKey")) { + return .{ .report = .{ .error_message = "opencode-go needs an apiKey credential" } }; + } + + const api_key = usage.asString(cred_root.get("apiKey")) orelse ""; + const cred_account_id = usage.asString(cred_root.get("accountId")); + const cred_email = usage.asString(cred_root.get("email")); + + const resp_parsed = std.json.parseFromSlice(std.json.Value, alloc, responses_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + const resp_array = usage.asArray(resp_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + + if (resp_array.items.len == 0) { + var headers = std.ArrayList([2][]const u8).empty; + const auth_hdr = try std.fmt.allocPrint(alloc, "Bearer {s}", .{api_key}); + try headers.append(alloc, .{ "Authorization", auth_hdr }); + try headers.append(alloc, .{ "Accept", "application/json" }); + try headers.append(alloc, .{ "User-Agent", "OpenCode-Status-Plugin/1.0" }); + + const requests = try alloc.alloc(usage.UsageRequest, 1); + requests[0] = .{ + .method = "GET", + .url = OPENCODE_GO_USAGE_URL, + .headers = headers.items, + }; + 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 limits = try parseOpenCodeGoUsage(alloc, body_parsed.value); + + return .{ + .report = .{ + .planType = "Go", + .accountId = cred_account_id, + .email = cred_email, + .limits = limits, + .notes = &[_][]const u8{"OpenCode Go unified models"}, + .error_message = null, + }, + }; +} diff --git a/src/providers/zai.zig b/src/providers/zai.zig new file mode 100644 index 0000000..f6568d2 --- /dev/null +++ b/src/providers/zai.zig @@ -0,0 +1,186 @@ +//! Z.ai usage feed implementation. +//! +//! Mirrors packages/ai/src/utils/usage/providers/zai.ts field for field. +const std = @import("std"); +const usage = @import("../usage.zig"); + +pub const ZAI_USAGE_URL = "https://api.z.ai/api/monitor/usage/quota/limit"; + +pub const ParseZaiResult = struct { + planType: ?[]const u8 = null, + limits: []const usage.UsageLimit = &[_]usage.UsageLimit{}, +}; + +pub fn parseZaiUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !ParseZaiResult { + const root = usage.asObject(payload); + const data = if (root) |r| usage.asObject(r.get("data")) else null; + if (data == null) return ParseZaiResult{}; + + var limits = std.ArrayList(usage.UsageLimit).empty; + const raw_limits = usage.asArray(data.?.get("limits")); + + if (raw_limits) |arr| { + for (arr.items) |entry| { + const item = usage.asObject(entry) orelse continue; + + const type_str = usage.asString(item.get("type")) orelse ""; + const unit = usage.asNumber(item.get("unit")); + const raw_pct = usage.asNumber(item.get("percentage")); + 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))) + else + null; + + var label: []const u8 = undefined; + var window_label: ?[]const u8 = null; + var detail: ?[]const u8 = 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) { + detail = try std.fmt.allocPrint(alloc, "{d} / {d} reqs", .{ + @as(i64, @intFromFloat(cur.?)), + @as(i64, @intFromFloat(total.?)), + }); + } + } else if (std.mem.eql(u8, type_str, "TOKENS_LIMIT") or (unit != null and (unit.? == 3.0 or unit.? == 6.0))) { + label = "Tokens"; + window_label = "5h window"; + } else { + label = if (type_str.len > 0) try usage.titleCase(alloc, type_str) else "Limit"; + } + + const status: []const u8 = if (used_percent != null and used_percent.? >= 100.0) + "exhausted" + else + usage.statusFor(used_percent); + + try limits.append(alloc, .{ + .label = label, + .usedPercent = used_percent, + .windowLabel = window_label, + .resetsAt = resets_at, + .detail = detail, + .status = status, + }); + } + } + + const level = usage.asString(data.?.get("level")); + const plan_type: ?[]const u8 = if (level) |l| blk: { + const trimmed = std.mem.trim(u8, l, " \t\r\n"); + if (trimmed.len == 0) break :blk null; + break :blk try usage.titleCase(alloc, trimmed); + } else null; + + return ParseZaiResult{ + .planType = plan_type, + .limits = limits.items, + }; +} + +pub fn advance( + alloc: std.mem.Allocator, + credential_json: []const u8, + responses_json: []const u8, + _now_ms: i64, +) !usage.Outcome { + _ = _now_ms; + const cred_parsed = std.json.parseFromSlice(std.json.Value, alloc, credential_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const cred_root = usage.asObject(cred_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid credential JSON" } }; + }; + const kind = usage.asString(cred_root.get("kind")) orelse ""; + if (!std.mem.eql(u8, kind, "apiKey")) { + return .{ .report = .{ .error_message = "zai needs an apiKey credential" } }; + } + + const api_key = usage.asString(cred_root.get("apiKey")) orelse ""; + const cred_account_id = usage.asString(cred_root.get("accountId")); + const cred_email = usage.asString(cred_root.get("email")); + + const resp_parsed = std.json.parseFromSlice(std.json.Value, alloc, responses_json, .{}) catch { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + const resp_array = usage.asArray(resp_parsed.value) orelse { + return .{ .report = .{ .error_message = "invalid responses JSON" } }; + }; + + if (resp_array.items.len == 0) { + var headers = std.ArrayList([2][]const u8).empty; + // zai requires authorization without Bearer prefix + try headers.append(alloc, .{ "Authorization", api_key }); + try headers.append(alloc, .{ "Accept", "application/json" }); + try headers.append(alloc, .{ "User-Agent", "OpenCode-Status-Plugin/1.0" }); + + const requests = try alloc.alloc(usage.UsageRequest, 1); + requests[0] = .{ + .method = "GET", + .url = ZAI_USAGE_URL, + .headers = headers.items, + }; + 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 root = usage.asObject(body_parsed.value); + const success_val = if (root) |r| r.get("success") else null; + const is_success = if (success_val) |sv| switch (sv) { + .bool => |b| b, + else => true, + } else true; + const code_num = if (root) |r| usage.asNumber(r.get("code")) else null; + + if (!is_success or (code_num != null and code_num.? != 200.0)) { + const msg = if (root) |r| usage.asString(r.get("msg")) else null; + 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))}); + return .{ .report = .{ .error_message = err_msg } }; + } + + const parsed = try parseZaiUsage(alloc, body_parsed.value); + + return .{ + .report = .{ + .planType = parsed.planType, + .accountId = cred_account_id, + .email = cred_email, + .limits = parsed.limits, + .error_message = null, + }, + }; +} diff --git a/src/usage.zig b/src/usage.zig index d546809..09375cf 100644 --- a/src/usage.zig +++ b/src/usage.zig @@ -1,59 +1,108 @@ -//! Provider usage query core. +//! Pure provider usage query core. //! -//! Pure by construction: no sockets, no clock, no filesystem. The host resolves -//! credentials, performs the request, and passes the body here, which is what lets a -//! macOS app use NSURLSession instead of us shipping a TLS stack. -//! -//! The shapes match the JSON contract in packages/ai/docs/usage-query.md; the -//! TypeScript implementation in packages/ai/src/utils/oauth/openai-codex.ts is the -//! reference, and tests compare the two on the same bodies. +//! Driven by the host: no sockets, no clock, no filesystem. +//! Implements ABI 2 (host-driven requests/report protocol). const std = @import("std"); +pub const iso8601 = @import("iso8601.zig"); -pub const abi_version: u32 = 1; +pub const abi_version: u32 = 2; -/// Providers with a feed, in stable order. The host uses this to name providers that -/// have no feed rather than implying none exist. -pub const supported_providers = [_][]const u8{"openai-codex"}; +pub const ProviderInfo = struct { + provider: []const u8, + providerName: []const u8, + credentialKinds: []const []const u8, +}; -pub const Outcome = union(enum) { - ok, - err: []const u8, +pub const supported_providers = [_]ProviderInfo{ + .{ + .provider = "alibaba", + .providerName = "Alibaba Model Studio", + .credentialKinds = &[_][]const u8{"session"}, + }, + .{ + .provider = "anthropic", + .providerName = "Anthropic", + .credentialKinds = &[_][]const u8{"oauth"}, + }, + .{ + .provider = "deepseek", + .providerName = "DeepSeek", + .credentialKinds = &[_][]const u8{"apiKey"}, + }, + .{ + .provider = "google-antigravity", + .providerName = "Google Antigravity", + .credentialKinds = &[_][]const u8{"oauth"}, + }, + .{ + .provider = "hyper", + .providerName = "Charm Hyper", + .credentialKinds = &[_][]const u8{ "oauth", "apiKey" }, + }, + .{ + .provider = "openai-codex", + .providerName = "ChatGPT Codex", + .credentialKinds = &[_][]const u8{"oauth"}, + }, + .{ + .provider = "opencode-go", + .providerName = "OpenCode Zen", + .credentialKinds = &[_][]const u8{"apiKey"}, + }, + .{ + .provider = "zai", + .providerName = "Z.ai", + .credentialKinds = &[_][]const u8{"apiKey"}, + }, }; -/// One quota window, mirroring the `UsageLimit` shape. -pub const Limit = struct { +pub const UsageLimit = struct { label: []const u8, usedPercent: ?f64 = null, windowLabel: ?[]const u8 = null, resetsAt: ?i64 = null, status: []const u8, + detail: ?[]const u8 = null, }; -/// Write one JSON string literal, escaping what JSON requires. -pub fn writeJsonString(w: *Writer, value: []const u8) !void { - try w.byte('"'); - for (value) |c| { - switch (c) { - '"' => try w.raw("\\\""), - '\\' => try w.raw("\\\\"), - '\n' => try w.raw("\\n"), - '\r' => try w.raw("\\r"), - '\t' => try w.raw("\\t"), - else => { - if (c < 0x20) { - try w.print("\\u{x:0>4}", .{c}); - } else { - try w.byte(c); - } - }, - } - } - try w.byte('"'); -} +pub const UsageRequest = struct { + method: []const u8 = "GET", + url: []const u8, + headers: []const [2][]const u8 = &[_][2][]const u8{}, + body: ?[]const u8 = 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, + err: []const u8, +}; + +pub const ReportOutcome = struct { + providerName: ?[]const u8 = null, + accountId: ?[]const u8 = null, + email: ?[]const u8 = null, + planType: ?[]const u8 = null, + limits: []const UsageLimit = &[_]UsageLimit{}, + balance: ?[]const u8 = null, + notes: []const []const u8 = &[_][]const u8{}, + error_message: ?[]const u8 = null, +}; -/// Append-only JSON writer over an arena. Deliberately hand-rolled: the shapes here are -/// a handful of fields, and a std.json stringify API would put a moving target between -/// the core and the contract. +/// Append-only JSON writer over an arena. pub const Writer = struct { buf: std.ArrayList(u8) = .empty, alloc: std.mem.Allocator, @@ -71,9 +120,6 @@ pub const Writer = struct { } pub fn print(self: *Writer, comptime fmt: []const u8, args: anytype) !void { - // Formatting into a temporary and appending keeps this independent of the - // std writer interface, which moves between Zig releases. Every call site has - // an arena behind it, so the temporary costs nothing that outlives the request. const text = try std.fmt.allocPrint(self.alloc, fmt, args); try self.buf.appendSlice(self.alloc, text); } @@ -87,7 +133,28 @@ pub const Writer = struct { } }; -fn asObject(value: ?std.json.Value) ?std.json.ObjectMap { +pub fn writeJsonString(w: *Writer, value: []const u8) !void { + try w.byte('"'); + for (value) |c| { + switch (c) { + '"' => try w.raw("\\\""), + '\\' => try w.raw("\\\\"), + '\n' => try w.raw("\\n"), + '\r' => try w.raw("\\r"), + '\t' => try w.raw("\\t"), + else => { + if (c < 0x20) { + try w.print("\\u{x:0>4}", .{c}); + } else { + try w.byte(c); + } + }, + } + } + try w.byte('"'); +} + +pub fn asObject(value: ?std.json.Value) ?std.json.ObjectMap { const v = value orelse return null; return switch (v) { .object => |o| o, @@ -95,7 +162,7 @@ fn asObject(value: ?std.json.Value) ?std.json.ObjectMap { }; } -fn asArray(value: ?std.json.Value) ?std.json.Array { +pub fn asArray(value: ?std.json.Value) ?std.json.Array { const v = value orelse return null; return switch (v) { .array => |a| a, @@ -103,9 +170,7 @@ fn asArray(value: ?std.json.Value) ?std.json.Array { }; } -/// Numbers arrive as integers, floats, or numeric strings; anything else is absent. -/// Matches `asNumber` in the TypeScript reference. -fn asNumber(value: ?std.json.Value) ?f64 { +pub fn asNumber(value: ?std.json.Value) ?f64 { const v = value orelse return null; return switch (v) { .integer => |i| @floatFromInt(i), @@ -116,19 +181,12 @@ fn asNumber(value: ?std.json.Value) ?f64 { }; } -/// "5h window" / "2d window"; absent when the window is unknown or non-positive. -pub fn formatWindowLabel(alloc: std.mem.Allocator, seconds: ?f64) !?[]const u8 { - const s = seconds orelse return null; - if (!(s > 0)) return null; - if (s >= 86_400) { - return try std.fmt.allocPrint(alloc, "{d}d window", .{roundToInt(s / 86_400)}); - } - const hours = roundToInt(s / 3_600); - return try std.fmt.allocPrint(alloc, "{d}h window", .{if (hours < 1) 1 else hours}); -} - -fn roundToInt(value: f64) i64 { - return @intFromFloat(@round(value)); +pub fn asString(value: ?std.json.Value) ?[]const u8 { + const v = value orelse return null; + return switch (v) { + .string => |s| s, + else => null, + }; } pub fn statusFor(used_percent: ?f64) []const u8 { @@ -138,138 +196,268 @@ pub fn statusFor(used_percent: ?f64) []const u8 { return "ok"; } -const Window = struct { - used_percent: ?f64 = null, - window: ?[]const u8 = null, - resets_at: ?i64 = null, -}; - -/// `reset_at` above 1e12 is already milliseconds; below it is seconds. Otherwise -/// `reset_after_seconds` counts from now. -fn parseWindow(alloc: std.mem.Allocator, value: ?std.json.Value, now_ms: i64) !?Window { - const obj = asObject(value) orelse return null; - const used_percent = asNumber(obj.get("used_percent")); - const window = try formatWindowLabel(alloc, asNumber(obj.get("limit_window_seconds"))); - - var resets_at: ?i64 = null; - if (asNumber(obj.get("reset_at"))) |reset_at| { - resets_at = if (reset_at > 1_000_000_000_000) @intFromFloat(reset_at) else @intFromFloat(reset_at * 1000); - } else if (asNumber(obj.get("reset_after_seconds"))) |after| { - resets_at = now_ms + @as(i64, @intFromFloat(after * 1000)); +pub fn titleCase(alloc: std.mem.Allocator, value: []const u8) ![]const u8 { + var parts = std.mem.splitAny(u8, value, "_-"); + var res = std.ArrayList(u8).empty; + var first = true; + while (parts.next()) |part| { + if (part.len == 0) continue; + if (!first) try res.append(alloc, ' '); + first = false; + try res.append(alloc, std.ascii.toUpper(part[0])); + for (part[1..]) |c| { + try res.append(alloc, std.ascii.toLower(c)); + } } - - return .{ .used_percent = used_percent, .window = window, .resets_at = resets_at }; + return res.items; } -/// Dispatch to the provider's parser. Adding a provider means a parser plus a line here. -pub fn parse(alloc: std.mem.Allocator, provider: []const u8, body: []const u8, now_ms: i64, w: *Writer) !Outcome { - if (std.mem.eql(u8, provider, "openai-codex")) return parseCodex(alloc, body, now_ms, w); - return .{ .err = "provider has no usage feed" }; +pub fn formatWindowLabel(alloc: std.mem.Allocator, seconds: ?f64) !?[]const u8 { + const s = seconds orelse return null; + if (!(s > 0)) return null; + if (s >= 86_400) { + const days = @as(i64, @intFromFloat(@round(s / 86_400))); + return try std.fmt.allocPrint(alloc, "{d}d window", .{days}); + } + const hours = @as(i64, @intFromFloat(@round(s / 3_600))); + return try std.fmt.allocPrint(alloc, "{d}h window", .{if (hours < 1) 1 else hours}); } -/// ChatGPT backend `wham/usage` body -> plan type and labelled windows. -pub fn parseCodex(alloc: std.mem.Allocator, body: []const u8, now_ms: i64, w: *Writer) !Outcome { - if (body.len == 0) return .{ .err = "usage body is empty" }; - var parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { - return .{ .err = "invalid JSON body" }; - }; - defer parsed.deinit(); - - const root = asObject(parsed.value) orelse return .{ .err = "usage body is not an object" }; - const rate_limit = asObject(root.get("rate_limit")); - - var limits: std.ArrayList(Limit) = .empty; +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 (seconds >= 86_400) { + const days = @as(i64, @intFromFloat(@round(@as(f64, @floatFromInt(seconds)) / 86_400.0))); + return try std.fmt.allocPrint(alloc, "{d}d window", .{days}); + } + const hours = @max(1, @as(i64, @intFromFloat(@round(@as(f64, @floatFromInt(seconds)) / 3600.0)))); + return try std.fmt.allocPrint(alloc, "{d}h window", .{hours}); +} - if (try parseWindow(alloc, if (rate_limit) |rl| rl.get("primary_window") else null, now_ms)) |window| { - try limits.append(alloc, .{ - .label = "Primary", - .usedPercent = window.used_percent, - .windowLabel = window.window, - .resetsAt = window.resets_at, - .status = statusFor(window.used_percent), - }); +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 body_str = asString(ro.get("body")); + if (body_str) |bs| { + if (std.json.parseFromSlice(std.json.Value, alloc, bs, .{})) |parsed| { + if (asObject(parsed.value)) |root| { + if (root.get("error")) |err_val| { + if (asString(err_val)) |err_str| { + const trimmed = std.mem.trim(u8, err_str, " \t\r\n"); + if (trimmed.len > 0) { + return try std.fmt.allocPrint(alloc, "HTTP {d}: {s}", .{ status, trimmed }); + } + } else if (asObject(err_val)) |err_obj| { + if (asString(err_obj.get("message"))) |msg| { + const trimmed = std.mem.trim(u8, msg, " \t\r\n"); + if (trimmed.len > 0) { + return try std.fmt.allocPrint(alloc, "HTTP {d}: {s}", .{ status, trimmed }); + } + } + } + } + } + } else |_| {} } - if (try parseWindow(alloc, if (rate_limit) |rl| rl.get("secondary_window") else null, now_ms)) |window| { - try limits.append(alloc, .{ - .label = "Secondary", - .usedPercent = window.used_percent, - .windowLabel = window.window, - .resetsAt = window.resets_at, - .status = statusFor(window.used_percent), - }); + return try std.fmt.allocPrint(alloc, "HTTP {d}", .{status}); +} + +pub fn urlEncode(alloc: std.mem.Allocator, input: []const u8) ![]const u8 { + var out = std.ArrayList(u8).empty; + const hex = "0123456789ABCDEF"; + for (input) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '-', '_', '.', '*' => try out.append(alloc, c), + ' ' => try out.append(alloc, '+'), + else => { + try out.append(alloc, '%'); + try out.append(alloc, hex[(c >> 4) & 0x0F]); + try out.append(alloc, hex[c & 0x0F]); + }, + } } + return out.items; +} - if (asArray(root.get("additional_rate_limits"))) |additional| { - for (additional.items) |entry| { - const item = asObject(entry) orelse continue; - const extra = asObject(item.get("rate_limit")); - const name = stringField(item.get("limit_name")) orelse - stringField(item.get("metered_feature")) orelse "Additional"; - if (try parseWindow(alloc, if (extra) |e| e.get("primary_window") else null, now_ms)) |window| { - try limits.append(alloc, .{ - .label = try std.fmt.allocPrint(alloc, "{s} · Primary", .{name}), - .usedPercent = window.used_percent, - .windowLabel = window.window, - .resetsAt = window.resets_at, - .status = statusFor(window.used_percent), - }); +pub fn writeStep(w: *Writer, outcome: Outcome) !void { + switch (outcome) { + .requests => |reqs| { + try w.raw("{\"requests\":["); + for (reqs, 0..) |req, i| { + if (i > 0) try w.byte(','); + try w.raw("{\"method\":"); + try w.string(req.method); + try w.raw(",\"url\":"); + try w.string(req.url); + try w.raw(",\"headers\":["); + for (req.headers, 0..) |h, hi| { + if (hi > 0) try w.byte(','); + try w.raw("["); + try w.string(h[0]); + try w.byte(','); + try w.string(h[1]); + try w.raw("]"); + } + try w.raw("]"); + if (req.body) |body| { + try w.raw(",\"body\":"); + try w.string(body); + } + try w.byte('}'); } - if (try parseWindow(alloc, if (extra) |e| e.get("secondary_window") else null, now_ms)) |window| { - try limits.append(alloc, .{ - .label = try std.fmt.allocPrint(alloc, "{s} · Secondary", .{name}), - .usedPercent = window.used_percent, - .windowLabel = window.window, - .resetsAt = window.resets_at, - .status = statusFor(window.used_percent), - }); + try w.raw("]}"); + }, + .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; + } + if (rep.email) |em| { + if (wrote) try w.byte(','); + try w.raw("\"email\":"); + try w.string(em); + wrote = true; + } + if (rep.planType) |pt| { + if (wrote) try w.byte(','); + try w.raw("\"planType\":"); + try w.string(pt); + wrote = true; } - } - } - const plan_type = stringField(root.get("plan_type")); + if (wrote) try w.byte(','); + try w.raw("\"limits\":["); + for (rep.limits, 0..) |lim, i| { + if (i > 0) try w.byte(','); + 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 (lim.windowLabel) |wl| { + try w.raw(",\"windowLabel\":"); + try w.string(wl); + } + if (lim.resetsAt) |ra| { + try w.print(",\"resetsAt\":{d}", .{ra}); + } + if (lim.detail) |dt| { + try w.raw(",\"detail\":"); + try w.string(dt); + } + try w.raw(",\"status\":"); + try w.string(lim.status); + try w.byte('}'); + } + try w.byte(']'); + wrote = true; - try w.raw("{"); - var wrote = false; - if (plan_type) |plan| { - try w.raw("\"planType\":"); - try w.string(plan); - wrote = true; + if (rep.balance) |bal| { + try w.raw(",\"balance\":"); + try w.string(bal); + } + if (rep.notes.len > 0) { + try w.raw(",\"notes\":["); + for (rep.notes, 0..) |note, ni| { + if (ni > 0) try w.byte(','); + try w.string(note); + } + try w.byte(']'); + } + + try w.raw(",\"error\":"); + if (rep.error_message) |err_msg| { + try w.string(err_msg); + } else { + try w.raw("null"); + } + try w.raw("}}"); + }, + .err => |msg| { + try w.raw("{\"error\":"); + try w.string(msg); + try w.raw("}"); + }, } - if (wrote) try w.byte(','); - try w.raw("\"limits\":["); - for (limits.items, 0..) |limit, i| { +} + +pub fn writeProviders(w: *Writer) !void { + try w.raw("["); + for (supported_providers, 0..) |p, i| { if (i > 0) try w.byte(','); - try w.raw("{\"label\":"); - try w.string(limit.label); - if (limit.usedPercent) |used| { - try w.print(",\"usedPercent\":{d}", .{used}); - } - if (limit.windowLabel) |window_label| { - try w.raw(",\"windowLabel\":"); - try w.string(window_label); + try w.raw("{\"provider\":"); + try w.string(p.provider); + try w.raw(",\"providerName\":"); + try w.string(p.providerName); + try w.raw(",\"credentialKinds\":["); + for (p.credentialKinds, 0..) |k, ki| { + if (ki > 0) try w.byte(','); + try w.string(k); } - if (limit.resetsAt) |resets_at| { - try w.print(",\"resetsAt\":{d}", .{resets_at}); - } - try w.raw(",\"status\":"); - try w.string(limit.status); - try w.byte('}'); + try w.raw("]}"); } - try w.raw("]}"); - return .ok; + try w.raw("]"); } -fn stringField(value: ?std.json.Value) ?[]const u8 { - const v = value orelse return null; - return switch (v) { - .string => |s| s, - else => null, - }; +// Re-export providers +pub const codex = @import("providers/codex.zig"); +pub const anthropic = @import("providers/anthropic.zig"); +pub const antigravity = @import("providers/antigravity.zig"); +pub const hyper = @import("providers/hyper.zig"); +pub const opencode_go = @import("providers/opencode_go.zig"); +pub const zai = @import("providers/zai.zig"); +pub const deepseek = @import("providers/deepseek.zig"); +pub const alibaba = @import("providers/alibaba.zig"); + +pub fn advance( + alloc: std.mem.Allocator, + 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); + } + return .{ .err = "unknown provider" }; } -/// OpenAI OAuth access token -> account id and email. The JWT payload is base64url and -/// may be unpadded; claims live under provider-namespaced keys. -pub fn claimsFromAccessToken(alloc: std.mem.Allocator, jwt: []const u8, w: *Writer) !Outcome { +pub const ClaimsOutcome = union(enum) { ok, err: []const u8 }; + +pub fn claimsFromAccessToken(alloc: std.mem.Allocator, jwt: []const u8, w: *Writer) !ClaimsOutcome { const payload = jwtPayload(alloc, jwt) catch return .{ .err = "access token is not a JWT" }; defer alloc.free(payload); @@ -282,10 +470,8 @@ pub fn claimsFromAccessToken(alloc: std.mem.Allocator, jwt: []const u8, w: *Writ const auth = asObject(root.get("https://api.openai.com/auth")); const profile = asObject(root.get("https://api.openai.com/profile")); - const account_id = if (auth) |a| stringField(a.get("chatgpt_account_id")) else null; - // The reference lowercases the profile email and drops a blank one, so hosts that - // key accounts by claim email agree with the CLI. - const email_claim = if (profile) |p| stringField(p.get("email")) else null; + const account_id = if (auth) |a| asString(a.get("chatgpt_account_id")) else null; + const email_claim = if (profile) |p| asString(p.get("email")) else null; const email: ?[]const u8 = if (email_claim) |raw| blk: { const trimmed = std.mem.trim(u8, raw, " \t\r\n"); if (trimmed.len == 0) break :blk null; @@ -309,7 +495,7 @@ pub fn claimsFromAccessToken(alloc: std.mem.Allocator, jwt: []const u8, w: *Writ return .ok; } -fn jwtPayload(alloc: std.mem.Allocator, jwt: []const u8) ![]u8 { +pub fn jwtPayload(alloc: std.mem.Allocator, jwt: []const u8) ![]u8 { var parts = std.mem.splitScalar(u8, jwt, '.'); _ = parts.next() orelse return error.NotAJwt; const encoded = parts.next() orelse return error.NotAJwt; @@ -322,49 +508,3 @@ fn jwtPayload(alloc: std.mem.Allocator, jwt: []const u8) ![]u8 { try decoder.decode(decoded, encoded); return decoded; } - -/// What the host must send: URL plus headers, so a native host can use its own HTTP -/// stack. Secrets stay with the caller; this only shapes them. -pub fn buildRequest( - provider: []const u8, - access_token: []const u8, - account_id: ?[]const u8, - w: *Writer, -) !Outcome { - if (!std.mem.eql(u8, provider, "openai-codex")) { - return .{ .err = "provider has no usage feed" }; - } - try w.raw("{\"url\":\"https://chatgpt.com/backend-api/wham/usage\",\"headers\":[[\"User-Agent\",\"prime-agent\"],[\"Authorization\",\"Bearer "); - try writeJsonStringInline(w, access_token); - try w.raw("\"]"); - if (account_id) |id| { - if (id.len > 0) { - try w.raw(",[\"ChatGPT-Account-Id\",\""); - try writeJsonStringInline(w, id); - try w.raw("\"]"); - } - } - try w.raw("]}"); - return .ok; -} - -/// Escape a string's body without the surrounding quotes, for the two places above -/// where a value is embedded mid-literal. -fn writeJsonStringInline(w: *Writer, value: []const u8) !void { - for (value) |c| { - switch (c) { - '"' => try w.raw("\\\""), - '\\' => try w.raw("\\\\"), - '\n' => try w.raw("\\n"), - '\r' => try w.raw("\\r"), - '\t' => try w.raw("\\t"), - else => { - if (c < 0x20) { - try w.print("\\u{x:0>4}", .{c}); - } else { - try w.byte(c); - } - }, - } - } -} diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 0000000..78c794d --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// Wraps the Zig usage core for native hosts. Build the library first: +// zig build (in native/usage, produces zig-out/lib/libusage.a) +// then: +// swift build --package-path native/usage/swift +// +// The core does no I/O: it builds the requests and parses the responses, and UsageDriver feeds it +// through URLSession. That is the whole point of the split — TLS, proxies and HTTP/2 stay with the +// host, and the rules stay in one place shared with the CLI. +let package = Package( + name: "UsageCore", + platforms: [.macOS(.v13)], + products: [ + .library(name: "UsageCore", targets: ["UsageCore"]), + .executable(name: "usage-smoke", targets: ["UsageSmoke"]), + ], + targets: [ + .systemLibrary(name: "CUsage", path: "Sources/CUsage"), + .target( + name: "UsageCore", + dependencies: ["CUsage"], + path: "Sources/UsageCore", + linkerSettings: [.unsafeFlags(["../zig-out/lib/libusage.a"])] + ), + .executableTarget( + name: "UsageSmoke", + dependencies: ["UsageCore"], + path: "Sources/UsageSmoke", + linkerSettings: [.unsafeFlags(["../zig-out/lib/libusage.a"])] + ), + ] +) diff --git a/swift/Sources/CUsage/module.modulemap b/swift/Sources/CUsage/module.modulemap new file mode 100644 index 0000000..175620f --- /dev/null +++ b/swift/Sources/CUsage/module.modulemap @@ -0,0 +1,4 @@ +module CUsage [system] { + header "../../../include/usage.h" + export * +} diff --git a/swift/Sources/UsageCore/UsageCore.swift b/swift/Sources/UsageCore/UsageCore.swift new file mode 100644 index 0000000..ddf8d33 --- /dev/null +++ b/swift/Sources/UsageCore/UsageCore.swift @@ -0,0 +1,160 @@ +import CUsage +import Foundation + +/// One quota window, matching the contract in packages/ai/docs/usage-feeds.md. +public struct UsageLimit: Codable, Sendable, Equatable { + public var label: String + public var usedPercent: Double? + public var windowLabel: String? + public var resetsAt: Int64? + public var status: String + public var detail: String? +} + +/// What a provider's feed reports for one account. +public struct UsageReport: Codable, Sendable, Equatable { + public var provider: String? + public var providerName: String? + public var accountId: String? + public var email: String? + public var planType: String? + public var limits: [UsageLimit] + public var balance: String? + public var notes: [String]? + public var error: String? +} + +/// A provider with a feed, as the core describes it. +public struct UsageProviderInfo: Codable, Sendable, Equatable { + public var provider: String + public var providerName: String + public var credentialKinds: [String] +} + +/// The request the core wants sent. The host does the sending. +/// +/// Headers arrive as ordered pairs across the C ABI, because HTTP allows a name to repeat and a +/// dictionary cannot. `headers` is the convenience view for the common case. +public struct UsageRequest: Codable, Sendable, Equatable { + public var method: String + public var url: String + public var headerPairs: [[String]]? + public var body: String? + + public var headers: [String: String] { + Dictionary( + (headerPairs ?? []).compactMap { pair in pair.count == 2 ? (pair[0], pair[1]) : nil }, + uniquingKeysWith: { _, last in last } + ) + } + + enum CodingKeys: String, CodingKey { + case method, url, body + case headerPairs = "headers" + } +} + +/// One response handed back to the core. +public struct UsageResponse: Codable, Sendable, Equatable { + public var status: Int + public var body: String + + public init(status: Int, body: String) { + self.status = status + self.body = body + } +} + +/// Either the next requests to send, or the finished report. +public enum UsageStep: Sendable { + case requests([UsageRequest]) + case report(UsageReport) + + /// A provider that could not report says so in its report rather than by throwing. + public var reportValue: UsageReport? { + if case .report(let report) = self { return report } + return nil + } +} + +public enum UsageCoreError: Error, CustomStringConvertible { + case missingLibraryResult + case invalidJSON(String) + case providerRefused(String) + + 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 + } + } +} + +public enum UsageCore { + public static var abiVersion: UInt32 { usage_abi_version() } + + public static func providers() throws -> [UsageProviderInfo] { + let json = try consume(usage_providers()) + return try JSONDecoder().decode([UsageProviderInfo].self, from: Data(json.utf8)) + } + + /// 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( + provider: String, + credentialJSON: String, + responses: [UsageResponse], + nowMs: Int64 + ) throws -> UsageStep { + let responsesJSON = String(data: try JSONEncoder().encode(responses), encoding: .utf8) ?? "[]" + + let raw = try consume( + provider.withCString { providerPtr in + credentialJSON.withCString { credentialPtr in + responsesJSON.withCString { responsesPtr in + usage_advance( + providerPtr, strlen(providerPtr), + credentialPtr, strlen(credentialPtr), + responsesPtr, strlen(responsesPtr), + nowMs + ) + } + } + } + ) + + guard let data = raw.data(using: .utf8), let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw UsageCoreError.invalidJSON(raw) + } + if object["requests"] != nil { + return .requests(try JSONDecoder().decode(UsageRequestsEnvelope.self, from: data).requests) + } + if let error = object["error"] as? String { + throw UsageCoreError.providerRefused(error) + } + return .report(try JSONDecoder().decode(UsageReportEnvelope.self, from: data).report) + } + + /// Account id and email from an OAuth access token, without a request. + public static func credentialsFromAccessToken(_ jwt: String) throws -> [String: String] { + let json = try consume( + jwt.withCString { pointer in + usage_credentials_from_access_token(pointer, strlen(pointer)) + } + ) + guard let data = json.data(using: .utf8) else { throw UsageCoreError.invalidJSON(json) } + return (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:] + } + + private struct UsageRequestsEnvelope: Codable { var requests: [UsageRequest] } + private struct UsageReportEnvelope: Codable { var report: UsageReport } + + /// Every call hands back a string the caller owns. NULL means allocation failed, which for a + /// quota read is a failure like any other, so it becomes an error instead of a crash. + private static func consume(_ pointer: UnsafeMutablePointer?) throws -> String { + guard let pointer else { throw UsageCoreError.missingLibraryResult } + defer { usage_free(pointer) } + return String(cString: pointer) + } +} diff --git a/swift/Sources/UsageCore/UsageDriver.swift b/swift/Sources/UsageCore/UsageDriver.swift new file mode 100644 index 0000000..66b615e --- /dev/null +++ b/swift/Sources/UsageCore/UsageDriver.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Drives a feed with URLSession: ask the core for requests, send them, hand the responses back, +/// repeat until it reports. The loop is the same one the TypeScript host runs, so a provider with +/// two dependent calls (teams, then billing) works here with no extra machinery. +public enum UsageDriver { + public static func fetch( + provider: String, + credentialJSON: String, + nowMs: Int64 = Int64(Date().timeIntervalSince1970 * 1000), + session: URLSession = URLSession(configuration: .ephemeral), + maxRounds: Int = 4 + ) async throws -> UsageReport { + var responses: [UsageResponse] = [] + + for _ in 0.. String { + var object: [String: Any] = ["kind": kind] + switch kind { + case "oauth": object["access"] = value + case "apiKey": object["apiKey"] = value + case "session": object["session"] = (try? JSONSerialization.jsonObject(with: Data(value.utf8))) ?? [:] + default: object[kind] = value + } + if let accountId { object["accountId"] = accountId } + if let email { object["email"] = email } + for (key, val) in extra { object[key] = val } + let data = try? JSONSerialization.data(withJSONObject: object) + return data.flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + } + + private static func send(_ request: UsageRequest, session: URLSession) async throws -> UsageResponse { + guard let url = URL(string: request.url) else { + return UsageResponse(status: 0, body: "invalid url: \(request.url)") + } + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method + for (name, value) in request.headers ?? [:] { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + if let body = request.body { + urlRequest.httpBody = Data(body.utf8) + } + do { + let (data, response) = try await session.data(for: urlRequest) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + return UsageResponse(status: status, body: String(data: data, encoding: .utf8) ?? "") + } catch { + // A transport failure is data for the feed, not an exception: the report carries it. + return UsageResponse(status: 0, body: "transport error: \(error.localizedDescription)") + } + } +} diff --git a/swift/Sources/UsageSmoke/main.swift b/swift/Sources/UsageSmoke/main.swift new file mode 100644 index 0000000..4583ccd --- /dev/null +++ b/swift/Sources/UsageSmoke/main.swift @@ -0,0 +1,133 @@ +import Foundation +import UsageCore + +// Proves the Swift -> 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). +func locateFixtures() -> URL { + if CommandLine.arguments.count > 1 { + return URL(fileURLWithPath: CommandLine.arguments[1]) + } + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + for candidate in ["../tests/fixtures", "tests/fixtures"] { + let url = cwd.appendingPathComponent(candidate) + if FileManager.default.fileExists(atPath: url.appendingPathComponent("codex-usage-live.json").path) { + return url + } + } + fail("cannot find tests/fixtures; pass the directory as the first argument") +} + +let fixtures = locateFixtures() + +func fixture(_ name: String) throws -> String { + try String(contentsOf: fixtures.appendingPathComponent(name), encoding: .utf8) +} + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) + exit(1) +} + +func check(_ condition: Bool, _ message: String) { + if !condition { fail(message) } +} + +do { + check(UsageCore.abiVersion == 2, "unexpected ABI version \(UsageCore.abiVersion)") + + let providers = try UsageCore.providers() + check(providers.contains { $0.provider == "openai-codex" }, "codex should be a known provider") + check(providers.first { $0.provider == "anthropic" }?.credentialKinds == ["oauth"], "anthropic takes oauth") + + let codexCredential = UsageDriver.credentialJSON(kind: "oauth", value: "test-token", email: "codex@example.com") + let firstStep = try UsageCore.advance(provider: "openai-codex", credentialJSON: codexCredential, responses: [], nowMs: 1_789_514_000_000) + guard case .requests(let requests) = firstStep else { fail("expected a request, not a report") } + check(requests.count == 1, "codex needs one request") + check(requests[0].url.hasSuffix("/wham/usage"), "unexpected codex url \(requests[0].url)") + check(requests[0].headers["Authorization"] == "Bearer test-token", "codex must send the bearer token") + + guard let codexReport = try UsageCore.advance( + provider: "openai-codex", + credentialJSON: codexCredential, + responses: [UsageResponse(status: 200, body: try fixture("codex-usage-live.json"))], + nowMs: 1_789_514_000_000 + ).reportValue else { fail("codex should report") } + + check(codexReport.planType == "prolite", "plan type \(codexReport.planType ?? "nil")") + 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. + 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") } + check(anthropicRequests.count == 2, "anthropic asks for usage and profile, got \(anthropicRequests.count)") + + guard let anthropicReport = try UsageCore.advance( + provider: "anthropic", + credentialJSON: anthropicCredential, + responses: [ + UsageResponse(status: 200, body: try fixture("anthropic-usage.json")), + UsageResponse(status: 200, body: try fixture("anthropic-profile.json")), + ], + nowMs: 1_700_000_000_000 + ).reportValue else { fail("anthropic should report") } + + check(anthropicReport.limits.count == 2, "expected session and weekly, got \(anthropicReport.limits.count)") + let weekly = anthropicReport.limits[1] + check(weekly.label == "Weekly", "second window is the week") + check(weekly.usedPercent == 94, "weekly is 94% used, got \(weekly.usedPercent ?? -1)") + check(weekly.resetsAt == 1_789_545_600_037, "ISO reset should become epoch ms, got \(weekly.resetsAt ?? -1)") + check(weekly.status == "warning", "severity critical is a warning below 100%") + check(anthropicReport.planType == "team_standard", "plan type from the profile") + + + // google-antigravity: the endpoint is gated on the antigravity user-agent, and the body reports + // REMAINING fraction, so usedPercent is an inversion. Both are pinned here because both are easy + // to "fix" into something that looks fine and is wrong. + let antigravityCredential = UsageDriver.credentialJSON(kind: "oauth", value: "ya29.test") + let antigravityStep = try UsageCore.advance(provider: "google-antigravity", credentialJSON: antigravityCredential, responses: [], nowMs: 1_789_514_000_000) + guard case .requests(let antigravityRequests) = antigravityStep else { fail("antigravity should ask for a request") } + check(antigravityRequests.count == 1, "antigravity needs one request") + check(antigravityRequests[0].method == "POST", "antigravity is a POST") + check(antigravityRequests[0].headers["User-Agent"]?.hasPrefix("antigravity/") == true, "without the antigravity user-agent the endpoint answers 403") + + guard let antigravityReport = try UsageCore.advance( + provider: "google-antigravity", + credentialJSON: antigravityCredential, + responses: [UsageResponse(status: 200, body: try fixture("antigravity-quota.json"))], + nowMs: 1_789_514_000_000 + ).reportValue else { fail("antigravity should report") } + + guard let gemini = antigravityReport.limits.first(where: { $0.label == "Gemini" }) else { + fail("antigravity should pool a Gemini family, got \(antigravityReport.limits.map(\.label))") + } + check(gemini.usedPercent == 83.4, "remaining 0.166 must invert to 83.4, got \(gemini.usedPercent ?? -1)") + check(gemini.detail == "17% free", "the detail keeps the free share, got \(gemini.detail ?? "nil")") + check(!antigravityReport.limits.contains { $0.label.lowercased().contains("chat") || $0.label.lowercased().contains("tab") }, "internal buckets must be dropped") + + // alibaba: the console session credential, and a FRACTION that has to become a percentage. + let alibabaSession = #"{"url":"https://bailian-singapore-cs.alibabacloud.com/data/api.json","region":"ap-southeast-1","secToken":"test-csrf","headers":{"cookie":"login_aliyunid=test"}}"# + let alibabaStep = try UsageCore.advance(provider: "alibaba", credentialJSON: UsageDriver.credentialJSON(kind: "session", value: alibabaSession), responses: [], nowMs: 1_789_514_000_000) + guard case .requests(let alibabaRequests) = alibabaStep else { fail("alibaba should ask for its three console calls") } + check(alibabaRequests.count == 3, "alibaba reads usage, subscription and reset cards, got \(alibabaRequests.count)") + check(alibabaRequests[0].method == "POST", "the console gateway is a POST") + check(alibabaRequests[0].headers["cookie"] == "login_aliyunid=test", "the captured cookies have to travel with the request") + check(alibabaRequests[0].body?.contains("sec_token=test-csrf") == true, "the form body carries the session csrf token") + check(alibabaRequests[0].body?.contains("region=ap-southeast-1") == true, "and the region") + + // An unknown provider is an error, not an empty report. + do { + _ = try UsageCore.advance(provider: "nope", credentialJSON: "{}", responses: [], nowMs: 0) + fail("an unknown provider should throw") + } catch UsageCoreError.providerRefused(let message) { + 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") +} catch { + fail("\(error)") +} diff --git a/tests/c_smoke.c b/tests/c_smoke.c index b757996..3e6fec9 100644 --- a/tests/c_smoke.c +++ b/tests/c_smoke.c @@ -18,24 +18,39 @@ static char *read_file(const char *path) { int main(int argc, char **argv) { if (argc < 2) return 2; - printf("abi=%u supported=%s\n", usage_abi_version(), usage_supported_providers()); + char *providers = usage_providers(); + printf("abi=%u providers=%s\n", usage_abi_version(), providers); + usage_free(providers); char *body = read_file(argv[1]); if (!body) { fprintf(stderr, "cannot read %s\n", argv[1]); return 2; } - char *report = usage_parse("openai-codex", strlen("openai-codex"), body, strlen(body), 1789514000000LL); - printf("%s\n", report); + 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); + 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); usage_free(report); - char *unknown = usage_parse("anthropic", strlen("anthropic"), body, strlen(body), 0); - printf("%s\n", unknown); + char *unknown = usage_advance("nonexistent", strlen("nonexistent"), cred, strlen(cred), "[]", 2, 0); + printf("unknown=%s\n", unknown); usage_free(unknown); - char *request = usage_build_request("openai-codex", strlen("openai-codex"), "tok", 3, "acct", 4); - printf("%s\n", request); - usage_free(request); - - char *none = usage_build_request("openai-codex", strlen("openai-codex"), NULL, 0, NULL, 0); + char *none = usage_advance(NULL, 0, NULL, 0, NULL, 0, 0); printf("null-safe=%s\n", none == NULL ? "null" : "ok"); usage_free(none); usage_free(NULL); diff --git a/tests/usage_test.zig b/tests/usage_test.zig index 91a74d1..380e89f 100644 --- a/tests/usage_test.zig +++ b/tests/usage_test.zig @@ -1,16 +1,39 @@ //! Tests for the pure core. Everything here runs offline: the core has no I/O to fake. const std = @import("std"); const usage = @import("usage"); +const iso8601 = usage.iso8601; const jwt = "eyJhbGciOiJSUzI1NiJ9.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdF90ZXN0XzEifSwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9wcm9maWxlIjp7ImVtYWlsIjoiVGVzdC5BY2NvdW50QEV4YW1wbGUuQ09NIn19.c2ln"; -fn parse(alloc: std.mem.Allocator, body: []const u8, now_ms: i64) ![]const u8 { +fn advance(alloc: std.mem.Allocator, provider: []const u8, cred: []const u8, resp: []const u8, now_ms: i64) ![]const u8 { var w = usage.Writer.init(alloc); - const outcome = try usage.parse(alloc, "openai-codex", body, now_ms, &w); - if (outcome == .err) return error.ParseFailed; + const outcome = try usage.advance(alloc, provider, cred, resp, now_ms); + try usage.writeStep(&w, outcome); return w.items(); } +test "iso-8601 parser formats" { + const cases = [_]struct { s: []const u8, expected: i64 }{ + .{ .s = "2026-09-16T08:00:00.037593+00:00", .expected = 1789545600037 }, + .{ .s = "2026-09-15T23:46:00Z", .expected = 1789515960000 }, + .{ .s = "2026-06-09T11:28:39.481128Z", .expected = 1781004519481 }, + .{ .s = "2026-01-01T00:00:00+05:30", .expected = 1767205800000 }, + .{ .s = "2026-01-01T00:00:00-08:00", .expected = 1767254400000 }, + .{ .s = "2026-01-01T00:00:00.5Z", .expected = 1767225600500 }, + .{ .s = "2026-01-01T00:00:00.12Z", .expected = 1767225600120 }, + .{ .s = "2026-01-01T00:00:00.123Z", .expected = 1767225600123 }, + .{ .s = "2026-01-01T00:00:00.1234Z", .expected = 1767225600123 }, + }; + + for (cases) |c| { + const got = iso8601.parseIso8601(c.s); + try std.testing.expectEqual(c.expected, got.?); + } + + try std.testing.expect(iso8601.parseIso8601("") == null); + try std.testing.expect(iso8601.parseIso8601("not-a-date") == null); +} + test "window labels match the reference formatting" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -31,109 +54,260 @@ test "status thresholds: exhausted at 100, warning at 90" { try std.testing.expectEqualStrings("unknown", usage.statusFor(null)); } -test "codex body: primary, secondary, and additional limits" { +test "codex first step requests" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); + const cred = "{\"kind\":\"oauth\",\"access\":\"test-token\"}"; + const text = try advance(a, "openai-codex", cred, "[]", 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"url\":\"https://chatgpt.com/backend-api/wham/usage\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "[\"Authorization\",\"Bearer test-token\"]") != null); +} + +test "codex second step report with windows and additional limits" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"oauth\",\"access\":\"test-token\",\"email\":\"user@example.com\"}"; const body = - \\{"plan_type":"prolite", - \\ "rate_limit":{"primary_window":{"used_percent":100,"limit_window_seconds":604800}, - \\ "secondary_window":{"used_percent":32.5,"limit_window_seconds":18000,"reset_after_seconds":3600}}, - \\ "additional_rate_limits":[{"limit_name":"Spark","rate_limit":{"primary_window":{"used_percent":0,"limit_window_seconds":18000}}}]} + \\[{"status":200,"body":"{\"plan_type\":\"prolite\",\"rate_limit\":{\"primary_window\":{\"used_percent\":100,\"limit_window_seconds\":604800},\"secondary_window\":{\"used_percent\":32.5,\"limit_window_seconds\":18000,\"reset_after_seconds\":3600}},\"additional_rate_limits\":[{\"limit_name\":\"Spark\",\"rate_limit\":{\"primary_window\":{\"used_percent\":0,\"limit_window_seconds\":18000}}}]}"}] ; const now: i64 = 1_700_000_000_000; - const text = try parse(a, body, now); + 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 \u{b7} Primary\"") != null); - // reset_after_seconds is relative to now + try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Spark · Primary\"") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"resetsAt\":1700003600000") != null); - // unknown percentages omit usedPercent but keep a status - try std.testing.expect(std.mem.indexOf(u8, text, "\"status\":\"unknown\"") == null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"email\":\"user@example.com\"") != null); +} + +test "anthropic first step requests" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"oauth\",\"access\":\"ant-token\"}"; + const text = try advance(a, "anthropic", cred, "[]", 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"https://api.anthropic.com/api/oauth/usage\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"https://api.anthropic.com/api/oauth/profile\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "[\"anthropic-beta\",\"oauth-2025-04-20\"]") != null); } -test "reset_at in seconds is scaled, milliseconds are kept" { +test "anthropic second step report" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); - const seconds = try parse(a, "{\"rate_limit\":{\"primary_window\":{\"used_percent\":1,\"reset_at\":1700000000}}}", 0); - try std.testing.expect(std.mem.indexOf(u8, seconds, "\"resetsAt\":1700000000000") != null); + const cred = "{\"kind\":\"oauth\",\"access\":\"ant-token\"}"; + const body = + \\[{"status":200,"body":"{\"limits\":[{\"kind\":\"session\",\"percent\":0,\"severity\":\"normal\"},{\"kind\":\"weekly_all\",\"percent\":94,\"severity\":\"critical\",\"resets_at\":\"2026-09-16T08:00:00.037593+00:00\"}],\"extra_usage\":{\"is_enabled\":false,\"credits_ever_enabled\":true}}"},{"status":200,"body":"{\"account\":{\"email\":\"User@Example.Com\",\"uuid\":\"uuid-123\"},\"organization\":{\"seat_tier\":\"team_standard\"}}"}] + ; + const text = try advance(a, "anthropic", cred, body, 0); - const millis = try parse(a, "{\"rate_limit\":{\"primary_window\":{\"used_percent\":1,\"reset_at\":1700000000000}}}", 0); - try std.testing.expect(std.mem.indexOf(u8, millis, "\"resetsAt\":1700000000000") != null); + 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, "\"notes\":[\"Extra usage is off\"]") != null); } -test "numeric strings parse like numbers, junk is absent" { +test "anthropic extra usage enabled" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); - const text = try parse(a, "{\"rate_limit\":{\"primary_window\":{\"used_percent\":\"75\"}}}", 0); - try std.testing.expect(std.mem.indexOf(u8, text, "\"usedPercent\":75,\"status\":\"ok\"") != null); + const cred = "{\"kind\":\"oauth\",\"access\":\"ant-token\"}"; + const body = + \\[{"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, "\"detail\":\"25 of 100 credits\"") != null); +} - const junk = try parse(a, "{\"rate_limit\":{\"primary_window\":{\"used_percent\":\"lots\"}}}", 0); - try std.testing.expect(std.mem.indexOf(u8, junk, "usedPercent") == null); - try std.testing.expect(std.mem.indexOf(u8, junk, "\"status\":\"unknown\"") != null); +test "access token claims give account id and a normalized email" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var w = usage.Writer.init(a); + const outcome = try usage.claimsFromAccessToken(a, jwt, &w); + try std.testing.expect(outcome == .ok); + try std.testing.expectEqualStrings("{\"accountId\":\"acct_test_1\",\"email\":\"test.account@example.com\"}", w.items()); + + var broken = usage.Writer.init(a); + try std.testing.expect((try usage.claimsFromAccessToken(a, "not-a-jwt", &broken)) == .err); } -test "unknown windows are skipped, missing rate_limit is empty" { +test "antigravity first step and report conversion" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); - const text = try parse(a, "{\"rate_limit\":{\"primary_window\":{\"used_percent\":5}}}", 0); - try std.testing.expect(std.mem.indexOf(u8, text, "windowLabel") == null); - try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Primary\"") != null); + const cred = "{\"kind\":\"oauth\",\"access\":\"ya29.test\",\"extra\":{\"projectId\":\"test-proj\"}}"; + const req_text = try advance(a, "google-antigravity", cred, "[]", 0); + try std.testing.expect(std.mem.indexOf(u8, req_text, "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "antigravity/hub/2.8.0") != null); - const empty = try parse(a, "{}", 0); - try std.testing.expectEqualStrings("{\"limits\":[]}", empty); + // Remaining fraction 0.16592255 converted to 83.4% used and 17% free + const body = + \\[{"status":200,"body":"{\"buckets\":[{\"modelId\":\"gemini-2.5-flash\",\"remainingFraction\":0.16592255,\"resetTime\":\"2026-09-15T23:46:00Z\"},{\"modelId\":\"chat_tab_drop\",\"remainingFraction\":1.0}]}"}] + ; + 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, "\"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); + try std.testing.expect(std.mem.indexOf(u8, rep_text, "\"notes\":[\"Project: test-proj\"]") != null); } -test "bad bodies are errors, not empty reports" { +test "antigravity HTTP error message unwrapping" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); - var w = usage.Writer.init(a); - try std.testing.expect((try usage.parse(a, "openai-codex", "not json", 0, &w)) == .err); + const cred = "{\"kind\":\"oauth\",\"access\":\"ya29.test\"}"; + const body = + \\[{"status":401,"body":"{\"error\":{\"code\":401,\"message\":\"Invalid token\"}}"}] + ; + const rep_text = try advance(a, "google-antigravity", cred, body, 0); + try std.testing.expect(std.mem.indexOf(u8, rep_text, "\"error\":\"HTTP 401: Invalid token\"") != null); +} + +test "hyper two-step flow and unit conversion" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); - var w2 = usage.Writer.init(a); - try std.testing.expect((try usage.parse(a, "anthropic", "{}", 0, &w2)) == .err); + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"sk-hyper-123\"}"; + + // Step 0 -> /v1/teams + const step0 = try advance(a, "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); + 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); + + // Step 2 -> billing: 237 remaining of 250 credits -> (250-237)/250 = 5.2% used + 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); + 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); + 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, "\"balance\":\"11.83\"") != null); + try std.testing.expect(std.mem.indexOf(u8, step2, "\"notes\":[\"Team: My Team\"]") != null); } -test "access token claims give account id and a normalized email" { +test "opencode-go status mapping and windows" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); - var w = usage.Writer.init(a); - const outcome = try usage.claimsFromAccessToken(a, jwt, &w); - try std.testing.expect(outcome == .ok); - try std.testing.expectEqualStrings("{\"accountId\":\"acct_test_1\",\"email\":\"test.account@example.com\"}", w.items()); + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"opencode-key\"}"; + const body = + \\[{"status":200,"body":"{\"usage\":{\"rolling\":{\"status\":\"ok\",\"percent\":10,\"resetsAt\":\"2026-09-16T04:40:14Z\"},\"monthly\":{\"status\":\"rate-limited\",\"percent\":50,\"resetsAt\":\"2026-09-29T07:01:56Z\"}}}"}] + ; + 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); + // 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, "\"notes\":[\"OpenCode Go unified models\"]") != null); +} - var broken = usage.Writer.init(a); - try std.testing.expect((try usage.claimsFromAccessToken(a, "not-a-jwt", &broken)) == .err); +test "zai authorization without Bearer, epoch seconds/ms, and ratio fallback" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"raw-zai-key\"}"; + const req_text = try advance(a, "zai", cred, "[]", 0); + // Authorization MUST NOT have Bearer prefix + try std.testing.expect(std.mem.indexOf(u8, req_text, "[\"Authorization\",\"raw-zai-key\"]") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "Bearer") == null); + + // Case 1: TIME_LIMIT with epoch ms (> 1e11) + // Case 2: TOKENS_LIMIT with epoch seconds (< 1e11) and ratio fallback (cur 25 / total 100 = 25%) + const body = + \\[{"status":200,"body":"{\"code\":200,\"success\":true,\"data\":{\"level\":\"lite\",\"limits\":[{\"type\":\"TIME_LIMIT\",\"unit\":5,\"currentValue\":10,\"usage\":100,\"percentage\":10,\"nextResetTime\":1792027143998},{\"type\":\"TOKENS_LIMIT\",\"currentValue\":25,\"number\":100,\"nextResetTime\":1792027143}]}}"}] + ; + 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); + // 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); } -test "request shape carries the token and only sends an account header when known" { +test "zai error return when code != 200" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); - var w = usage.Writer.init(a); - try std.testing.expect((try usage.buildRequest("openai-codex", "tok", "acct", &w)) == .ok); - try std.testing.expectEqualStrings( - "{\"url\":\"https://chatgpt.com/backend-api/wham/usage\",\"headers\":[[\"User-Agent\",\"prime-agent\"],[\"Authorization\",\"Bearer tok\"],[\"ChatGPT-Account-Id\",\"acct\"]]}", - w.items(), - ); - - var without = usage.Writer.init(a); - try std.testing.expect((try usage.buildRequest("openai-codex", "tok", null, &without)) == .ok); - try std.testing.expect(std.mem.indexOf(u8, without.items(), "ChatGPT-Account-Id") == null); - - var foreign = usage.Writer.init(a); - try std.testing.expect((try usage.buildRequest("anthropic", "tok", null, &foreign)) == .err); + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"key\"}"; + const body = + \\[{"status":200,"body":"{\"code\":401,\"success\":false,\"msg\":\"Token invalid\"}"}] + ; + const text = try advance(a, "zai", cred, body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"error\":\"Token invalid\"") != null); +} + +test "deepseek balance formatting and unavailable account note" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"apiKey\",\"apiKey\":\"ds-key\"}"; + const body = + \\[{"status":200,"body":"{\"is_available\":false,\"balance_infos\":[{\"currency\":\"USD\",\"total_balance\":\"0.03\"}]}"}] + ; + const text = try advance(a, "deepseek", cred, body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"planType\":\"Pay-as-you-go\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"balance\":\"$0.03 USD\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"limits\":[]") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"notes\":[\"Account is unavailable\"]") != null); +} + +test "alibaba 3 POST requests with form encoding and weekly fraction conversion" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"session\",\"session\":{\"secToken\":\"sec123\",\"region\":\"ap-southeast-1\"}}"; + const req_text = try advance(a, "alibaba", cred, "[]", 0); + // 3 requests issued + try std.testing.expect(std.mem.indexOf(u8, req_text, "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/reset-card/list") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "params=%7B%22Api%22") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "sec_token=sec123") != null); + + // Response with weekly fraction 0.83603712139856 -> Weekly limit with ~83.6037% used + const body = + \\[{"status":200,"body":"{\"data\":{\"api\":\"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage\",\"DataV2\":{\"data\":{\"data\":{\"per1WeekPercentage\":0.83603712139856,\"per1WeekResetTime\":1790021160000}}}}}"},{"status":200,"body":"{\"data\":{\"api\":\"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription\",\"DataV2\":{\"data\":{\"data\":{\"specCode\":\"pro\",\"instanceCode\":\"inst-123\",\"remainingDays\":29}}}}}"},{"status":200,"body":"{\"data\":{\"api\":\"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/reset-card/list\",\"DataV2\":{\"data\":{\"data\":[{\"cardId\":\"c1\"}]}}}}"}] + ; + + const text = try advance(a, "alibaba", cred, body, 0); + try std.testing.expect(std.mem.indexOf(u8, text, "\"planType\":\"pro\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"accountId\":\"inst-123\"") != 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); + try std.testing.expect(std.mem.indexOf(u8, text, "\"detail\":\"29 days remaining\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "29 days remaining") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "1 reset card available") != null); }