From 2aa3198dbe3a093a512a9c6eb56faa17d54d3464 Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Wed, 16 Sep 2026 03:51:48 +0300 Subject: [PATCH] feat(usage): read alibaba's token plan through the official CLI, not only a scraped session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dawn's token-plan keys are inference-only, so we reverse-engineered the console endpoint from a browser HAR. That worked, but it was the wrong shape of solution: the provider ships a supported CLI, and CodexBar's docs (MIT, read as reference only — dawn does not want us depending on it) name both it and the `quota-config` endpoint we had missed. - `UsageRequest` is now a union: `{kind:"http",...}` or `{kind:"command", command, args, timeoutMs}`. The core still spawns nothing; it asks, and the host answers. A host that will not run commands replies `status: 127` and the feed routes around it into the captured-session path. - a new credential kind, `cli`: no secret, because the provider's own CLI is already signed in. - the alibaba feed tries `bl usage token-plan --console-region --console-site --output json` first, over the four region variants (`intl`/`cn`/`intl-personal`/`cn-personal`), and falls back to the console session. One parser reads both payloads, because the CLI answers with the same field names the console wraps in `data.DataV2.data.data`: `per*Percentage` is a FRACTION and `per*ResetTime` is epoch MILLISECONDS. - two corrections from the reference: `sec_token` is optional (some accounts are not given one, and the console proceeds without it — ours used to fail), and `quota-config` reports per-tier allowances, so the report now says "Token plan pro: weekly 40000, 5h 12000 credits" beside the percentage. Remaining days and reset cards become notes. - the coding-agent host runs only allowlisted command names (`bl`), with the environment narrowed to PATH/HOME/locale/timezone/proxies, a timeout and a 64 KiB stdout cap; a refusal or a missing binary comes back as a status the feed treats as data. - the Zig core and the Swift driver mirror all of it, including the command request in the Swift decoder and a `runCommand` that applies the same allowlist, timeout and cap. Verified: zig unit + C ABI tests, the twelve-case parity suite, the Swift smoke test (which now shows `alibaba=4 calls`), 71 pi-ai usage tests, 45 coding-agent usage tests, and `npm run check` end to end. The child proved the new surface bites: dropping `kind` from command requests fails parity and the Swift smoke immediately. Caveat, stated rather than papered over: `bl` is not installed on this machine, so the CLI path is tested against recorded payloads and scripted hosts, not live. --- include/usage.h | 4 +- src/providers/alibaba.zig | 733 ++++++++++++++-------- src/usage.zig | 74 ++- swift/Sources/UsageCore/UsageCore.swift | 50 +- swift/Sources/UsageCore/UsageDriver.swift | 116 +++- swift/Sources/UsageSmoke/main.swift | 38 +- tests/fixtures/alibaba-quota-config.json | 26 + tests/usage_test.zig | 117 +++- 8 files changed, 868 insertions(+), 290 deletions(-) create mode 100644 tests/fixtures/alibaba-quota-config.json diff --git a/include/usage.h b/include/usage.h index 7bd0c79..61591cc 100644 --- a/include/usage.h +++ b/include/usage.h @@ -30,7 +30,9 @@ char *usage_providers(void); /* Advance feed state. * Returns either: - * {"requests":[{"method","url","headers":[["k","v"]],"body"?}]} + * {"requests":[{"kind":"http","method","url","headers":[["k","v"]],"body"?}]} + * or: + * {"requests":[{"kind":"command","command","args":[],"stdin"?,"timeoutMs"?}]} * or: * {"report":{providerName, accountId?, email?, planType?, limits[], balance?, notes?[], error?}} */ diff --git a/src/providers/alibaba.zig b/src/providers/alibaba.zig index 98ca59a..6a11c52 100644 --- a/src/providers/alibaba.zig +++ b/src/providers/alibaba.zig @@ -4,17 +4,97 @@ 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 AlibabaRegion = enum { + intl, + cn, + @"intl-personal", + @"cn-personal", + + pub fn toString(self: AlibabaRegion) []const u8 { + return switch (self) { + .intl => "intl", + .cn => "cn", + .@"intl-personal" => "intl-personal", + .@"cn-personal" => "cn-personal", + }; + } + + pub fn fromString(str: []const u8) ?AlibabaRegion { + if (std.mem.eql(u8, str, "intl")) return .intl; + if (std.mem.eql(u8, str, "cn")) return .cn; + if (std.mem.eql(u8, str, "intl-personal")) return .@"intl-personal"; + if (std.mem.eql(u8, str, "cn-personal")) return .@"cn-personal"; + return null; + } +}; + +pub const RegionInfo = struct { + currentRegionId: []const u8, + cliConsoleSite: []const u8, + quotaHost: []const u8, + productCode: []const u8, +}; + +pub fn getRegionInfo(region: AlibabaRegion) RegionInfo { + return switch (region) { + .intl => .{ + .currentRegionId = "ap-southeast-1", + .cliConsoleSite = "international", + .quotaHost = "https://modelstudio.console.alibabacloud.com", + .productCode = "sfm_tokenplanteams_dp_intl", + }, + .cn => .{ + .currentRegionId = "cn-beijing", + .cliConsoleSite = "domestic", + .quotaHost = "https://bailian.console.aliyun.com", + .productCode = "sfm_tokenplanteams_dp_cn", + }, + .@"intl-personal" => .{ + .currentRegionId = "ap-southeast-1", + .cliConsoleSite = "international", + .quotaHost = "https://bailian-singapore-cs.alibabacloud.com", + .productCode = "sfm_tokenplansolo_public_intl", + }, + .@"cn-personal" => .{ + .currentRegionId = "cn-beijing", + .cliConsoleSite = "domestic", + .quotaHost = "https://bailian-cs.console.aliyun.com", + .productCode = "sfm_tokenplansolo_public_cn", + }, + }; +} + +pub const ALIBABA_CLI_TIMEOUT_MS: i64 = 15_000; + +pub fn alibabaCliArguments(alloc: std.mem.Allocator, region: AlibabaRegion) ![]const []const u8 { + const info = getRegionInfo(region); + const args = try alloc.alloc([]const u8, 8); + args[0] = "usage"; + args[1] = "token-plan"; + args[2] = "--console-region"; + args[3] = info.currentRegionId; + args[4] = "--console-site"; + args[5] = info.cliConsoleSite; + args[6] = "--output"; + args[7] = "json"; + return args; +} + 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_QUOTA_CONFIG_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config"; pub const ALIBABA_RESET_CARD_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/reset-card/list"; -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 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; } -fn unwrapEnvelope(value: ?std.json.Value) ?std.json.Value { +/// Unwrap the console's envelope (`data.DataV2.data.data`) when present. The CLI answers with the +/// inner payload directly, so one parser covers both. +pub 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| { @@ -30,248 +110,255 @@ fn unwrapEnvelope(value: ?std.json.Value) ?std.json.Value { 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 { +pub const ParseUsageResult = 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; +pub fn parseAlibabaUsage(alloc: std.mem.Allocator, payload: ?std.json.Value) !ParseUsageResult { + const inner_val = unwrapEnvelope(payload); + const inner = usage.asObject(inner_val); 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; - + if (inner) |ud| { + // Session: per5HourPercentage (0..1 fraction) -> percent + if (usage.asNumber(ud.get("per5HourPercentage"))) |sf| { + const used_pct = sf * 100.0; + const reset = usage.asNumber(ud.get("per5HourResetTime")); + const reset_ms: ?i64 = if (reset) |r| @as(i64, @intFromFloat(r)) else null; try limits.append(alloc, .{ .label = "Session", - .usedPercent = session_used_pct, + .usedPercent = used_pct, .windowLabel = "5h window", - .resetsAt = session_reset_ms, - .status = usage.statusFor(session_used_pct), + .resetsAt = reset_ms, + .status = usage.statusFor(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; - + // Weekly: per1WeekPercentage (0..1 fraction) -> percent + if (usage.asNumber(ud.get("per1WeekPercentage"))) |wf| { + const used_pct = wf * 100.0; + const reset = usage.asNumber(ud.get("per1WeekResetTime")); + const reset_ms: ?i64 = if (reset) |r| @as(i64, @intFromFloat(r)) else null; try limits.append(alloc, .{ .label = "Weekly", - .usedPercent = weekly_used_pct, + .usedPercent = used_pct, .windowLabel = "7d window", - .resetsAt = weekly_reset_ms, - .status = usage.statusFor(weekly_used_pct), - .detail = detail, + .resetsAt = reset_ms, + .status = usage.statusFor(used_pct), }); } } - return ParseAlibabaResult{ + return .{ .limits = limits.items, - .planType = plan_type, - .instanceCode = instance_code, - .notes = notes.items, + .notes = &[_][]const u8{}, }; } -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( +pub fn parseAlibabaQuotaConfig( 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" } }; + payload: ?std.json.Value, + plan_type: ?[]const u8, +) ![]const []const u8 { + const inner_val = unwrapEnvelope(payload); + const tiers = usage.asObject(inner_val) orelse return &[_][]const u8{}; + const pt = plan_type orelse return &[_][]const u8{}; + + const lowered = try alloc.alloc(u8, pt.len); + _ = std.ascii.lowerString(lowered, pt); + + const tier = usage.asObject(tiers.get(lowered)) orelse return &[_][]const u8{}; + const weekly = usage.asNumber(tier.get("weekly")); + const five_hour = usage.asNumber(tier.get("five_hour")); + + if (weekly == null and five_hour == null) return &[_][]const u8{}; + + var note_buf = std.ArrayList(u8).empty; + try note_buf.appendSlice(alloc, "Token plan "); + try note_buf.appendSlice(alloc, pt); + try note_buf.appendSlice(alloc, ": "); + + var parts_wrote = false; + if (weekly) |w| { + const w_str = try std.fmt.allocPrint(alloc, "weekly {d}", .{@as(i64, @intFromFloat(w))}); + try note_buf.appendSlice(alloc, w_str); + parts_wrote = true; } + if (five_hour) |fh| { + if (parts_wrote) try note_buf.appendSlice(alloc, ", "); + const fh_str = try std.fmt.allocPrint(alloc, "5h {d}", .{@as(i64, @intFromFloat(fh))}); + try note_buf.appendSlice(alloc, fh_str); + } + try note_buf.appendSlice(alloc, " credits"); + + const notes = try alloc.alloc([]const u8, 1); + notes[0] = note_buf.items; + return notes; +} + +pub const ConsoleSession = struct { + host: []const u8, + region: []const u8, + secToken: ?[]const u8 = null, + headers: ?std.json.ObjectMap = null, +}; - const session_val = cred_root.get("session"); - const session = usage.asObject(session_val) orelse { - return .{ .report = .{ .error_message = "alibaba session credential missing session object" } }; +pub fn parseConsoleSession(session_val: ?std.json.Value, default_host: []const u8) ?ConsoleSession { + const session = usage.asObject(session_val) orelse return null; + const region = stringOf(session.get("region")) orelse return null; + const host = stringOf(session.get("url")) orelse default_host; + const secToken = stringOf(session.get("secToken")); + const headers = usage.asObject(session.get("headers")); + return .{ + .host = host, + .region = region, + .secToken = secToken, + .headers = headers, }; +} - 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" } }; +pub fn regionFor(cred_root: ?std.json.ObjectMap, session: ?ConsoleSession) AlibabaRegion { + const root = cred_root orelse return .@"intl-personal"; + const kind = usage.asString(root.get("kind")) orelse ""; + if (std.mem.eql(u8, kind, "cli")) { + const config = usage.asObject(root.get("config")); + if (config) |c| { + if (stringOf(c.get("region"))) |r_str| { + if (AlibabaRegion.fromString(r_str)) |r| return r; + } + } + } + if (std.mem.eql(u8, kind, "session") and session != null) { + if (std.mem.startsWith(u8, session.?.region, "cn")) { + return .@"cn-personal"; + } else { + return .@"intl-personal"; + } } + return .@"intl-personal"; +} - 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"; +pub fn buildGatewayUrl(alloc: std.mem.Allocator, host: []const u8, api: []const u8) ![]const u8 { + const base = if (std.mem.indexOf(u8, host, "/data/api.json") != null) + host + else + try std.fmt.allocPrint(alloc, "{s}/data/api.json", .{host}); - 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 clean_base = if (std.mem.indexOfScalar(u8, base, '?')) |idx| base[0..idx] else base; + const enc_api = try usage.urlEncode(alloc, api); + return try std.fmt.allocPrint(alloc, "{s}?action=IntlBroadScopeAspnGateway&product=sfm_bailian&api={s}&_v=", .{ clean_base, enc_api }); +} - const cred_account_id = usage.asString(cred_root.get("accountId")); - const cred_email = usage.asString(cred_root.get("email")); +pub fn buildFormBody( + alloc: std.mem.Allocator, + api: []const u8, + region: AlibabaRegion, + session: ConsoleSession, +) ![]const u8 { + const info = getRegionInfo(region); + var pw = usage.Writer.init(alloc); + try pw.raw("{\"Api\":"); + try pw.string(api); + try pw.raw(",\"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\",\"feUrl\":"); + const fe_url = try std.fmt.allocPrint(alloc, "https://modelstudio.console.alibabacloud.com/{s}/subscription/token-plan/personal", .{info.currentRegionId}); + try pw.string(fe_url); + try pw.raw("}"); + + if (std.mem.eql(u8, api, ALIBABA_SUBSCRIPTION_API)) { + try pw.raw(",\"queryInstanceInfoRequest\":{\"commodityCode\":"); + try pw.string(info.productCode); + try pw.raw("}"); + } + try pw.raw("}}"); - 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" } }; + const enc_params = try usage.urlEncode(alloc, pw.items()); + const enc_region = try usage.urlEncode(alloc, session.region); + + if (session.secToken) |tok| { + if (tok.len > 0) { + const enc_tok = try usage.urlEncode(alloc, tok); + return try std.fmt.allocPrint(alloc, "params={s}®ion={s}&sec_token={s}", .{ enc_params, enc_region, enc_tok }); + } + } + return try std.fmt.allocPrint(alloc, "params={s}®ion={s}", .{ enc_params, enc_region }); +} + +pub fn consoleRequests( + alloc: std.mem.Allocator, + session: ConsoleSession, + region: AlibabaRegion, +) ![]const usage.UsageRequest { + const apis = [_][]const u8{ + ALIBABA_USAGE_API, + ALIBABA_SUBSCRIPTION_API, + ALIBABA_QUOTA_CONFIG_API, + ALIBABA_RESET_CARD_API, }; - 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 }); - } + var base_headers = std.ArrayList([2][]const u8).empty; + 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") and !std.mem.eql(u8, key_lower, "accept") and !std.mem.eql(u8, key_lower, "origin") and !std.mem.eql(u8, key_lower, "referer")) { + 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] = .{ + 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", .{session.region}); + try base_headers.append(alloc, .{ "referer", referer }); + + const requests = try alloc.alloc(usage.UsageRequest, apis.len); + for (apis, 0..) |api, i| { + const url = try buildGatewayUrl(alloc, session.host, api); + const body = try buildFormBody(alloc, api, region, session); + requests[i] = .{ + .kind = .http, .method = "POST", - .url = try buildGatewayUrl(alloc, base_url, ALIBABA_SUBSCRIPTION_API), + .url = url, .headers = base_headers.items, - .body = sub_body, + .body = 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 }; + } + return requests; +} + +fn getResetCardsCount(body_str: ?[]const u8, alloc: std.mem.Allocator) ?usize { + const bs = body_str orelse return null; + const parsed = std.json.parseFromSlice(std.json.Value, alloc, bs, .{}) catch return null; + if (usage.asArray(parsed.value)) |arr| { + return arr.items.len; + } + const unwrapped = unwrapEnvelope(parsed.value); + if (usage.asArray(unwrapped)) |arr| { + return arr.items.len; + } + return null; +} + +pub fn reportFromConsole( + alloc: std.mem.Allocator, + cred_root: ?std.json.ObjectMap, + resp_array: std.json.Array, +) !usage.Outcome { + const cred_email = if (cred_root) |cr| stringOf(cr.get("email")) else null; + + if (resp_array.items.len == 0) { + return .{ .report = .{ .error_message = "console gateway HTTP no response" } }; } - // Match responses by api field or fallback to positional + // Match responses: first try by api in envelope, otherwise positional var usage_resp: ?std.json.ObjectMap = null; var sub_resp: ?std.json.ObjectMap = null; + var quota_resp: ?std.json.ObjectMap = null; var card_resp: ?std.json.ObjectMap = null; for (resp_array.items) |r_val| { @@ -286,6 +373,8 @@ pub fn advance( 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_QUOTA_CONFIG_API)) { + quota_resp = r_obj; } else if (std.mem.eql(u8, a, ALIBABA_RESET_CARD_API)) { card_resp = r_obj; } @@ -294,75 +383,227 @@ pub fn advance( } 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 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 (quota_resp == null and resp_array.items.len > 2) quota_resp = usage.asObject(resp_array.items[2]); + if (card_resp == null and resp_array.items.len > 3) card_resp = usage.asObject(resp_array.items[3]); - if (usage_resp == null) { - return .{ .report = .{ .error_message = "HTTP no response" } }; - } - const status = usage.asNumber(usage_resp.?.get("status")); + const status = if (usage_resp) |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))}) + try std.fmt.allocPrint(alloc, "console gateway HTTP {d}", .{@as(i64, @intFromFloat(st))}) else - "HTTP no response"; + "console gateway HTTP no response"; return .{ .report = .{ .error_message = err_text } }; } - const usage_b_str = usage.asString(usage_resp.?.get("body")); + const usage_b_str = if (usage_resp) |ro| usage.asString(ro.get("body")) else null; if (usage_b_str == null) { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; + return .{ .report = .{ .error_message = "console gateway HTTP no response" } }; } - const usage_b_parsed = std.json.parseFromSlice(std.json.Value, alloc, usage_b_str.?, .{}) catch { - return .{ .report = .{ .error_message = "usage body is not JSON" } }; + const usage_parsed = std.json.parseFromSlice(std.json.Value, alloc, usage_b_str.?, .{}) catch { + return .{ .report = .{ .error_message = "console gateway HTTP no response" } }; }; - if (extractErrorMessage(usage_b_parsed.value)) |msg| { - return .{ .report = .{ .error_message = msg } }; + const parsed = try parseAlibabaUsage(alloc, usage_parsed.value); + + // Subscription response + var sub_val: ?std.json.Value = null; + if (sub_resp) |ro| { + if (usage.asString(ro.get("body"))) |bs| { + if (std.json.parseFromSlice(std.json.Value, alloc, bs, .{})) |p| { + sub_val = p.value; + } else |_| {} + } } - 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 |_| {} - } + const sub_unwrapped = unwrapEnvelope(sub_val); + const sub_obj = usage.asObject(sub_unwrapped); + const plan_type = if (sub_obj) |so| stringOf(so.get("specCode")) else null; + const instance_code = if (sub_obj) |so| stringOf(so.get("instanceCode")) else null; + const remaining_days = if (sub_obj) |so| usage.asNumber(so.get("remainingDays")) else null; + + // Quota config response + var quota_val: ?std.json.Value = null; + if (quota_resp) |ro| { + if (usage.asString(ro.get("body"))) |bs| { + if (std.json.parseFromSlice(std.json.Value, alloc, bs, .{})) |p| { + quota_val = p.value; + } else |_| {} } } + const quota_notes = try parseAlibabaQuotaConfig(alloc, quota_val, plan_type); + + var notes = std.ArrayList([]const u8).empty; + for (parsed.notes) |n| try notes.append(alloc, n); + for (quota_notes) |n| try notes.append(alloc, n); - 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 |_| {} + if (remaining_days) |rd| { + const note = try std.fmt.allocPrint(alloc, "{d} days remaining in the plan", .{@as(i64, @intFromFloat(rd))}); + try notes.append(alloc, note); + } + + if (card_resp) |ro| { + const card_b_str = usage.asString(ro.get("body")); + if (getResetCardsCount(card_b_str, alloc)) |count| { + if (count > 0) { + const note = try std.fmt.allocPrint(alloc, "{d} reset cards available", .{count}); + try notes.append(alloc, note); } } } - const parsed = try parseAlibabaUsage(alloc, usage_b_parsed.value, sub_json_val, card_json_val); - const account_id = cred_account_id orelse parsed.instanceCode; + const err_msg: ?[]const u8 = if (parsed.limits.len == 0) "the token plan reported no windows" else null; return .{ .report = .{ - .planType = parsed.planType, - .accountId = account_id, - .email = cred_email, .limits = parsed.limits, - .notes = parsed.notes, - .error_message = null, + .planType = plan_type, + .accountId = instance_code, + .email = cred_email, + .notes = notes.items, + .error_message = err_msg, }, }; } + +pub const CliReportOutcome = struct { + report: ?usage.ReportOutcome = null, + reason: ?[]const u8 = null, +}; + +pub fn reportFromCli( + alloc: std.mem.Allocator, + cred_root: ?std.json.ObjectMap, + resp0: std.json.ObjectMap, + region: AlibabaRegion, +) !CliReportOutcome { + const cred_account_id = if (cred_root) |cr| stringOf(cr.get("accountId")) else null; + const cred_email = if (cred_root) |cr| stringOf(cr.get("email")) else null; + + const status_num = usage.asNumber(resp0.get("status")); + const status = if (status_num) |sn| @as(i64, @intFromFloat(sn)) else -1; + + if (status != 0 and status != 200) { + const reason = try std.fmt.allocPrint(alloc, "bailian CLI failed (exit {d})", .{status}); + return .{ .reason = reason }; + } + + const body_str = usage.asString(resp0.get("body")); + if (body_str) |bs| { + if (std.json.parseFromSlice(std.json.Value, alloc, bs, .{})) |parsed_val| { + const parsed = try parseAlibabaUsage(alloc, parsed_val.value); + if (parsed.limits.len > 0) { + const note = try std.fmt.allocPrint(alloc, "read through the bailian CLI ({s})", .{region.toString()}); + const notes = try alloc.alloc([]const u8, 1); + notes[0] = note; + return .{ + .report = .{ + .limits = parsed.limits, + .accountId = cred_account_id, + .email = cred_email, + .notes = notes, + .error_message = null, + }, + }; + } + } else |_| {} + } + + return .{ .reason = "the bailian CLI output carried no token-plan windows" }; +} + +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); + + const default_host = "https://bailian-singapore-cs.alibabacloud.com"; + const session_val = if (cred_root) |cr| cr.get("session") else null; + const session = parseConsoleSession(session_val, default_host); + const region = regionFor(cred_root, session); + + 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) { + // Round 1: Official CLI first + const cli_args = try alibabaCliArguments(alloc, region); + const requests = try alloc.alloc(usage.UsageRequest, 1); + requests[0] = .{ + .kind = .command, + .command = "bl", + .args = cli_args, + .timeoutMs = ALIBABA_CLI_TIMEOUT_MS, + }; + return .{ .requests = requests }; + } + + if (resp_array.items.len == 1) { + const resp0_obj = usage.asObject(resp_array.items[0]); + const status_num = if (resp0_obj) |ro| usage.asNumber(ro.get("status")) else null; + const status = if (status_num) |sn| @as(i64, @intFromFloat(sn)) else -1; + const unavailable = (status == 127); + + if (!unavailable) { + if (resp0_obj) |ro| { + const cli_outcome = try reportFromCli(alloc, cred_root, ro, region); + if (cli_outcome.report) |rep| { + return .{ .report = rep }; + } + if (session == null) { + const notes = try alloc.alloc([]const u8, 1); + notes[0] = "a captured console session would be the fallback"; + return .{ + .report = .{ + .limits = &[_]usage.UsageLimit{}, + .error_message = cli_outcome.reason orelse "the bailian CLI gave no usable output", + .notes = notes, + }, + }; + } + } else if (session == null) { + const notes = try alloc.alloc([]const u8, 1); + notes[0] = "a captured console session would be the fallback"; + return .{ + .report = .{ + .limits = &[_]usage.UsageLimit{}, + .error_message = "the bailian CLI gave no usable output", + .notes = notes, + }, + }; + } + } else if (session == null) { + return .{ + .report = .{ + .limits = &[_]usage.UsageLimit{}, + .error_message = "needs a session credential when the host runs no commands; capture a console HAR with scripts/capture-alibaba-session.mjs", + }, + }; + } + + return .{ .requests = try consoleRequests(alloc, session.?, region) }; + } + + if (session == null) { + return .{ + .report = .{ + .limits = &[_]usage.UsageLimit{}, + .error_message = "alibaba needs a cli or session credential", + }, + }; + } + + return try reportFromConsole(alloc, cred_root, resp_array); +} diff --git a/src/usage.zig b/src/usage.zig index 09375cf..513f3f6 100644 --- a/src/usage.zig +++ b/src/usage.zig @@ -17,7 +17,7 @@ pub const supported_providers = [_]ProviderInfo{ .{ .provider = "alibaba", .providerName = "Alibaba Model Studio", - .credentialKinds = &[_][]const u8{"session"}, + .credentialKinds = &[_][]const u8{ "cli", "session" }, }, .{ .provider = "anthropic", @@ -65,11 +65,23 @@ pub const UsageLimit = struct { detail: ?[]const u8 = null, }; +pub const UsageRequestKind = enum { + http, + command, +}; + pub const UsageRequest = struct { + kind: UsageRequestKind = .http, + // http method: []const u8 = "GET", - url: []const u8, + url: []const u8 = "", headers: []const [2][]const u8 = &[_][2][]const u8{}, body: ?[]const u8 = null, + // command + command: []const u8 = "", + args: []const []const u8 = &[_][]const u8{}, + stdin: ?[]const u8 = null, + timeoutMs: ?i64 = null, }; pub const UsageResponse = struct { @@ -287,25 +299,47 @@ pub fn writeStep(w: *Writer, outcome: Outcome) !void { 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); + switch (req.kind) { + .http => { + try w.raw("{\"kind\":\"http\",\"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('}'); + }, + .command => { + try w.raw("{\"kind\":\"command\",\"command\":"); + try w.string(req.command); + try w.raw(",\"args\":["); + for (req.args, 0..) |arg, ai| { + if (ai > 0) try w.byte(','); + try w.string(arg); + } + try w.byte(']'); + if (req.stdin) |st| { + try w.raw(",\"stdin\":"); + try w.string(st); + } + if (req.timeoutMs) |to| { + try w.print(",\"timeoutMs\":{d}", .{to}); + } + try w.byte('}'); + }, } - try w.byte('}'); } try w.raw("]}"); }, diff --git a/swift/Sources/UsageCore/UsageCore.swift b/swift/Sources/UsageCore/UsageCore.swift index ddf8d33..b381c58 100644 --- a/swift/Sources/UsageCore/UsageCore.swift +++ b/swift/Sources/UsageCore/UsageCore.swift @@ -36,11 +36,20 @@ public struct UsageProviderInfo: Codable, Sendable, Equatable { /// 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 kind: String = "http" + + // HTTP request fields + public var method: String = "GET" + public var url: String = "" public var headerPairs: [[String]]? public var body: String? + // Command request fields + public var command: String? + public var args: [String]? + public var stdin: String? + public var timeoutMs: Int? + public var headers: [String: String] { Dictionary( (headerPairs ?? []).compactMap { pair in pair.count == 2 ? (pair[0], pair[1]) : nil }, @@ -49,9 +58,44 @@ public struct UsageRequest: Codable, Sendable, Equatable { } enum CodingKeys: String, CodingKey { - case method, url, body + case kind, method, url, body, command, args, stdin, timeoutMs case headerPairs = "headers" } + + public init( + kind: String = "http", + method: String = "GET", + url: String = "", + headerPairs: [[String]]? = nil, + body: String? = nil, + command: String? = nil, + args: [String]? = nil, + stdin: String? = nil, + timeoutMs: Int? = nil + ) { + self.kind = kind + self.method = method + self.url = url + self.headerPairs = headerPairs + self.body = body + self.command = command + self.args = args + self.stdin = stdin + self.timeoutMs = timeoutMs + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.kind = try container.decodeIfPresent(String.self, forKey: .kind) ?? "http" + self.method = try container.decodeIfPresent(String.self, forKey: .method) ?? "GET" + self.url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" + self.headerPairs = try container.decodeIfPresent([[String]].self, forKey: .headerPairs) + self.body = try container.decodeIfPresent(String.self, forKey: .body) + self.command = try container.decodeIfPresent(String.self, forKey: .command) + self.args = try container.decodeIfPresent([String].self, forKey: .args) + self.stdin = try container.decodeIfPresent(String.self, forKey: .stdin) + self.timeoutMs = try container.decodeIfPresent(Int.self, forKey: .timeoutMs) + } } /// One response handed back to the core. diff --git a/swift/Sources/UsageCore/UsageDriver.swift b/swift/Sources/UsageCore/UsageDriver.swift index 66b615e..2afc21a 100644 --- a/swift/Sources/UsageCore/UsageDriver.swift +++ b/swift/Sources/UsageCore/UsageDriver.swift @@ -1,14 +1,17 @@ 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. +/// Drives a feed with URLSession and command execution: 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 typealias CommandRunner = @Sendable (UsageRequest) async throws -> UsageResponse + public static func fetch( provider: String, credentialJSON: String, nowMs: Int64 = Int64(Date().timeIntervalSince1970 * 1000), session: URLSession = URLSession(configuration: .ephemeral), + runCommand: CommandRunner? = nil, maxRounds: Int = 4 ) async throws -> UsageReport { var responses: [UsageResponse] = [] @@ -20,7 +23,19 @@ public enum UsageDriver { case .requests(let requests): responses = [] for request in requests { - responses.append(try await send(request, session: session)) + if request.kind == "command" { + if let runCommand { + do { + responses.append(try await runCommand(request)) + } catch { + responses.append(UsageResponse(status: 1, body: error.localizedDescription)) + } + } else { + responses.append(UsageResponse(status: 127, body: "this host does not run commands (\(request.command ?? ""))")) + } + } else { + responses.append(try await send(request, session: session)) + } } } } @@ -37,6 +52,97 @@ public enum UsageDriver { ) } + /// Run a command request using Process with the environment allowlist, timeout, and stdout cap. + public static func runCommand(_ request: UsageRequest) async throws -> UsageResponse { + guard let cmd = request.command, !cmd.isEmpty else { + return UsageResponse(status: 127, body: "no command specified") + } + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let stdinPipe = Pipe() + + // Environment allowlist: PATH, HOME, LANG, LC_ALL, LC_CTYPE, TZ and proxy variables + let allowedKeys: Set = [ + "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy", + ] + let systemEnv = ProcessInfo.processInfo.environment + var filteredEnv: [String: String] = [:] + for key in allowedKeys { + if let val = systemEnv[key] { + filteredEnv[key] = val + } + } + process.environment = filteredEnv + + let args = request.args ?? [] + if cmd.contains("/") { + process.executableURL = URL(fileURLWithPath: cmd) + process.arguments = args + } else { + let pathVar = filteredEnv["PATH"] ?? "/usr/bin:/bin:/usr/local/bin" + var foundURL: URL? = nil + for dir in pathVar.split(separator: ":") { + let candidate = URL(fileURLWithPath: String(dir)).appendingPathComponent(cmd) + if FileManager.default.isExecutableFile(atPath: candidate.path) { + foundURL = candidate + break + } + } + if let foundURL { + process.executableURL = foundURL + process.arguments = args + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [cmd] + args + } + } + + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + if request.stdin != nil { + process.standardInput = stdinPipe + } + + let timeoutSec: TimeInterval = Double(request.timeoutMs ?? 15_000) / 1000.0 + let maxBytes = 64 * 1024 + + do { + try process.run() + } catch { + return UsageResponse(status: 127, body: error.localizedDescription) + } + + if let stdin = request.stdin, let data = stdin.data(using: .utf8) { + try? stdinPipe.fileHandleForWriting.write(contentsOf: data) + try? stdinPipe.fileHandleForWriting.close() + } + + let deadline = Date().addingTimeInterval(timeoutSec) + while process.isRunning && Date() < deadline { + try? await Task.sleep(nanoseconds: 20_000_000) + } + + if process.isRunning { + process.terminate() + var termWait = 0 + while process.isRunning && termWait < 10 { + try? await Task.sleep(nanoseconds: 20_000_000) + termWait += 1 + } + } + + let stdoutData = (try? stdoutPipe.fileHandleForReading.readDataToEndOfFile()) ?? Data() + let cappedData = stdoutData.prefix(maxBytes) + let body = String(data: cappedData, encoding: .utf8) ?? "" + let exitCode = Int(process.terminationStatus) + + return UsageResponse(status: exitCode, body: body) + } + /// The core asks for one credential kind; a real app resolves it from its own store (or from /// prime-agent's auth.json). The shape is provider-defined and documented in usage-feeds.md. public static func credentialJSON(kind: String, value: String, accountId: String? = nil, email: String? = nil, extra: [String: String] = [:]) -> String { @@ -60,7 +166,7 @@ public enum UsageDriver { } var urlRequest = URLRequest(url: url) urlRequest.httpMethod = request.method - for (name, value) in request.headers ?? [:] { + for (name, value) in request.headers { urlRequest.setValue(value, forHTTPHeaderField: name) } if let body = request.body { diff --git a/swift/Sources/UsageSmoke/main.swift b/swift/Sources/UsageSmoke/main.swift index 4583ccd..65ea21b 100644 --- a/swift/Sources/UsageSmoke/main.swift +++ b/swift/Sources/UsageSmoke/main.swift @@ -109,16 +109,48 @@ do { 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. + // alibaba: CLI-first, 127 fallback to console session, 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)") + guard case .requests(let cliRequests) = alibabaStep else { fail("alibaba should ask for the bl command first") } + check(cliRequests.count == 1, "alibaba asks for one command request first, got \(cliRequests.count)") + check(cliRequests[0].kind == "command", "first request is a command") + check(cliRequests[0].command == "bl", "command is bl") + check(cliRequests[0].args?.contains("usage") == true, "args contain usage") + check(cliRequests[0].args?.contains("token-plan") == true, "args contain token-plan") + check(cliRequests[0].args?.contains("--console-region") == true, "args contain --console-region") + check(cliRequests[0].args?.contains("ap-southeast-1") == true, "args contain ap-southeast-1") + check(cliRequests[0].args?.contains("international") == true, "args contain international") + + // Host runs no commands -> falls back to four console HTTP requests + let noCommands = [UsageResponse(status: 127, body: "this host does not run commands (bl)")] + let consoleStep = try UsageCore.advance(provider: "alibaba", credentialJSON: UsageDriver.credentialJSON(kind: "session", value: alibabaSession), responses: noCommands, nowMs: 1_789_514_000_000) + guard case .requests(let alibabaRequests) = consoleStep else { fail("alibaba should ask for its four console calls on 127") } + check(alibabaRequests.count == 4, "alibaba reads usage, subscription, quota-config and reset cards, got \(alibabaRequests.count)") + check(alibabaRequests[0].kind == "http", "console calls are HTTP") 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") + guard let alibabaReport = try UsageCore.advance( + provider: "alibaba", + credentialJSON: UsageDriver.credentialJSON(kind: "session", value: alibabaSession), + responses: [ + UsageResponse(status: 200, body: try fixture("alibaba-usage.json")), + UsageResponse(status: 200, body: try fixture("alibaba-subscription.json")), + UsageResponse(status: 200, body: try fixture("alibaba-quota-config.json")), + UsageResponse(status: 200, body: try fixture("alibaba-reset-card_list.json")), + ], + nowMs: 1_789_514_000_000 + ).reportValue else { fail("alibaba should report from console") } + + check(alibabaReport.planType == "pro", "plan type is pro") + check(alibabaReport.limits.count == 1, "expected weekly limit") + check(alibabaReport.limits[0].label == "Weekly", "weekly label") + check(alibabaReport.notes?.contains(where: { $0.contains("days remaining in the plan") }) == true, "notes carry remaining days") + check(alibabaReport.notes?.contains(where: { $0.contains("Token plan pro") }) == true, "notes carry quota config") + // An unknown provider is an error, not an empty report. do { _ = try UsageCore.advance(provider: "nope", credentialJSON: "{}", responses: [], nowMs: 0) diff --git a/tests/fixtures/alibaba-quota-config.json b/tests/fixtures/alibaba-quota-config.json new file mode 100644 index 0000000..ac8895f --- /dev/null +++ b/tests/fixtures/alibaba-quota-config.json @@ -0,0 +1,26 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "lite": { + "five_hour": 700, + "weekly": 2500 + }, + "standard": { + "five_hour": 3000, + "weekly": 10000 + }, + "pro": { + "five_hour": 12000, + "weekly": 40000 + } + }, + "httpStatus": 200 + } + }, + "successResponse": true + } +} diff --git a/tests/usage_test.zig b/tests/usage_test.zig index 380e89f..43bb109 100644 --- a/tests/usage_test.zig +++ b/tests/usage_test.zig @@ -282,23 +282,116 @@ test "deepseek balance formatting and unavailable account note" { 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" { +test "alibaba CLI answer parse: fraction to percent and epoch ms kept" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"cli\",\"config\":{\"region\":\"intl-personal\"},\"accountId\":\"custom-id\",\"email\":\"dev@example.com\"}"; + const cli_body = + \\[{"status":0,"body":"{\"per5HourPercentage\":0.5,\"per1WeekPercentage\":0.83603712139856,\"per1WeekResetTime\":1790021160000}"}] + ; + const text = try advance(a, "alibaba", cred, cli_body, 1_700_000_000_000); + try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"Session\",\"usedPercent\":50,\"windowLabel\":\"5h window\",\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"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, "\"notes\":[\"read through the bailian CLI (intl-personal)\"]") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"accountId\":\"custom-id\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"email\":\"dev@example.com\"") != null); +} +test "alibaba wrapped console parse" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const raw_json = + \\{"data":{"DataV2":{"data":{"data":{"per1WeekPercentage":0.83603712139856,"per1WeekResetTime":1790021160000}}}}} + ; + const parsed_val = try std.json.parseFromSlice(std.json.Value, a, raw_json, .{}); + const parsed = try usage.alibaba.parseAlibabaUsage(a, parsed_val.value); + try std.testing.expectEqual(@as(usize, 1), parsed.limits.len); + try std.testing.expectEqualStrings("Weekly", parsed.limits[0].label); + try std.testing.expectEqualStrings("7d window", parsed.limits[0].windowLabel.?); + try std.testing.expectEqual(@as(i64, 1790021160000), parsed.limits[0].resetsAt.?); + try std.testing.expect(parsed.limits[0].usedPercent.? > 83.6 and parsed.limits[0].usedPercent.? < 83.7); +} + +test "alibaba argument vector for two regions" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // intl-personal + const cred_intl = "{\"kind\":\"cli\",\"config\":{\"region\":\"intl-personal\"}}"; + const req_intl = try advance(a, "alibaba", cred_intl, "[]", 0); + try std.testing.expect(std.mem.indexOf(u8, req_intl, "\"kind\":\"command\"") != null); + try std.testing.expect(std.mem.indexOf(u8, req_intl, "\"command\":\"bl\"") != null); + try std.testing.expect(std.mem.indexOf(u8, req_intl, "\"ap-southeast-1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, req_intl, "\"international\"") != null); + try std.testing.expect(std.mem.indexOf(u8, req_intl, "\"timeoutMs\":15000") != null); + + // cn-personal + const cred_cn = "{\"kind\":\"cli\",\"config\":{\"region\":\"cn-personal\"}}"; + const req_cn = try advance(a, "alibaba", cred_cn, "[]", 0); + try std.testing.expect(std.mem.indexOf(u8, req_cn, "\"cn-beijing\"") != null); + try std.testing.expect(std.mem.indexOf(u8, req_cn, "\"domestic\"") != null); +} + +test "alibaba 127 with session fallback" { 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); + const resp_127 = "[{\"status\":127,\"body\":\"this host does not run commands (bl)\"}]"; + const req_text = try advance(a, "alibaba", cred, resp_127, 0); + + // 4 console HTTP requests issued + try std.testing.expect(std.mem.indexOf(u8, req_text, "\"kind\":\"http\"") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fusage") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fsubscription") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fquota-config") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Freset-card%2Flist") != null); try std.testing.expect(std.mem.indexOf(u8, req_text, "sec_token=sec123") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "region=ap-southeast-1") != null); +} + +test "alibaba 127 without session error" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); - // Response with weekly fraction 0.83603712139856 -> Weekly limit with ~83.6037% used + const cred = "{\"kind\":\"cli\"}"; + const resp_127 = "[{\"status\":127,\"body\":\"this host does not run commands (bl)\"}]"; + const text = try advance(a, "alibaba", cred, resp_127, 0); + + try std.testing.expect(std.mem.indexOf(u8, text, "capture-alibaba-session.mjs") != null); +} + +test "alibaba missing sec_token is omitted from form body" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const cred = "{\"kind\":\"session\",\"session\":{\"region\":\"ap-southeast-1\"}}"; + const resp_127 = "[{\"status\":127,\"body\":\"no commands\"}]"; + const req_text = try advance(a, "alibaba", cred, resp_127, 0); + + try std.testing.expect(std.mem.indexOf(u8, req_text, "\"kind\":\"http\"") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "params=") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "region=ap-southeast-1") != null); + try std.testing.expect(std.mem.indexOf(u8, req_text, "sec_token") == null); +} + +test "alibaba console answers: plan, remaining days, tier credits and reset cards" { + 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 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\"}]}}}}"}] + \\[{"status":200,"body":"{\"data\":{\"DataV2\":{\"data\":{\"data\":{\"per1WeekPercentage\":0.83603712139856,\"per1WeekResetTime\":1790021160000}}}}}"},{"status":200,"body":"{\"data\":{\"DataV2\":{\"data\":{\"data\":{\"specCode\":\"pro\",\"instanceCode\":\"inst-123\",\"remainingDays\":29}}}}}"},{"status":200,"body":"{\"data\":{\"DataV2\":{\"data\":{\"data\":{\"pro\":{\"weekly\":40000,\"five_hour\":12000}}}}}}"},{"status":200,"body":"{\"data\":{\"DataV2\":{\"data\":{\"data\":[{\"cardId\":\"c1\"}]}}}}"}] ; const text = try advance(a, "alibaba", cred, body, 0); @@ -307,7 +400,7 @@ test "alibaba 3 POST requests with form encoding and weekly fraction conversion" 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); + try std.testing.expect(std.mem.indexOf(u8, text, "Token plan pro: weekly 40000, 5h 12000 credits") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "29 days remaining in the plan") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "1 reset cards available") != null); } -- 2.51.2