@zanii/blackbox 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,6 +83,12 @@ verifyAnswerCredential(credential, lines, bodies, packs); // { ok, problems }: t
83
83
 
84
84
  The same checks run offline as pure functions: `toolHallucinations`, `groundingFindings`, `factsIn`, `loadReferencePacks`, `answerRisk`, `detectorAccuracy`.
85
85
 
86
+ ## New in 0.14.0
87
+
88
+ - Price uplifts (`uplifts` in a price table): US-only inference, regional endpoints and fast modes priced above list, per deployment (`upliftPercent`).
89
+ - Statements in any currency: the `fx:<CUR>:<rate>` unit, `fxRate(currency)` (the ECB's rate through Frankfurter), and `blackbox statement --currency EUR` with the rate, date and source in the statement.
90
+ - Tax invoices for zero-rated and reverse-charge customers (`vat_treatment`).
91
+
86
92
  ## New in 0.13.0
87
93
 
88
94
  - `fromOtlp(body)`: OpenTelemetry spans (GenAI semconv or OpenInference) as session events, the mapping behind gateway v0.15's `POST /v1/traces`. Point any framework's OTLP exporter at the gateway with the session's token, and its spans join the record.
@@ -31,6 +31,8 @@ export interface Plans {
31
31
  }
32
32
  /** Parses a plans file (spec/billing.md §1); throws on anything that isn't integer fils. */
33
33
  export declare function loadPlans(bytes: Uint8Array): Plans;
34
+ /** spec/billing.md §5b: how VAT applies to a customer. */
35
+ export type VatTreatment = "standard" | "zero_rated" | "reverse_charge";
34
36
  export interface TaxInvoiceInput {
35
37
  number: string;
36
38
  issued_at: string;
@@ -53,6 +55,8 @@ export interface TaxInvoiceInput {
53
55
  sessions: number;
54
56
  };
55
57
  vat_percent?: number;
58
+ /** §5b: `zero_rated` and `reverse_charge` invoice VAT at 0%, with a note saying why. */
59
+ vat_treatment?: VatTreatment;
56
60
  }
57
61
  /** spec/billing.md §5: the tax invoice document. */
58
62
  export declare function taxInvoice(input: TaxInvoiceInput): {
@@ -90,6 +94,8 @@ export declare function taxInvoice(input: TaxInvoiceInput): {
90
94
  events: number;
91
95
  sessions: number;
92
96
  };
97
+ vat_treatment?: "reverse_charge" | "zero_rated";
98
+ vat_note?: string;
93
99
  total_aed: string;
94
100
  };
95
101
  export type TaxInvoice = ReturnType<typeof taxInvoice>;
@@ -69,6 +69,10 @@ export function loadPlans(bytes) {
69
69
  }
70
70
  return doc;
71
71
  }
72
+ const VAT_NOTES = {
73
+ zero_rated: "Zero-rated supply (export of services): VAT at 0%",
74
+ reverse_charge: "Reverse charge: the customer accounts for the VAT",
75
+ };
72
76
  /** The month's `[from, to)` and its last day (spec/billing.md §5). */
73
77
  function period(month) {
74
78
  const [y, m] = month.split("-").map(Number);
@@ -79,7 +83,8 @@ function period(month) {
79
83
  /** spec/billing.md §5: the tax invoice document. */
80
84
  export function taxInvoice(input) {
81
85
  const p = period(input.month);
82
- const inv = invoice(input.plan, input.usage, input.vat_percent ?? 5);
86
+ const treatment = input.vat_treatment ?? "standard";
87
+ const inv = invoice(input.plan, input.usage, treatment === "standard" ? (input.vat_percent ?? 5) : 0);
83
88
  return {
84
89
  v: 1,
85
90
  title: "Tax Invoice",
@@ -101,6 +106,9 @@ export function taxInvoice(input) {
101
106
  plan: { id: input.plan_id, name: input.plan.name, version: input.plans_version },
102
107
  usage: { events: input.usage.events, sessions: input.usage.sessions },
103
108
  ...inv,
109
+ ...(treatment !== "standard"
110
+ ? { vat_treatment: treatment, vat_note: VAT_NOTES[treatment] }
111
+ : {}),
104
112
  total_aed: formatAed(inv.total_fils),
105
113
  };
106
114
  }
@@ -146,6 +154,7 @@ export function renderTaxInvoice(d) {
146
154
  `| VAT ${d.vat_percent}% | ${aed(d.vat_fils)} |`,
147
155
  `| **Total** | **${aed(d.total_fils)}** |`,
148
156
  "",
157
+ ...(d.vat_note ? [`VAT: ${d.vat_note}.`, ""] : []),
149
158
  `Plan: ${oneLine(d.plan.name)} (${d.plan.id}, plans ${d.plan.version}). Usage: ${grouped(d.usage.events)} events, ${grouped(d.usage.sessions)} sessions.`,
150
159
  "",
151
160
  ];
package/dist/cli.js CHANGED
@@ -43,7 +43,7 @@ const USAGE = `usage (spec/cli.md):
43
43
  blackbox policy-from-zanii <zanii-policy.json> [--out <file>]
44
44
  blackbox policy-to-zanii <policy.json> [--out <file>]
45
45
  blackbox statement --provider anthropic|openai --from <YYYY-MM-DD> --to <YYYY-MM-DD> [--out <file>]
46
- blackbox statement --provider csv --file <f> --model-column <name> --amount-column <name> --unit usd|cents|micro_usd|aed [--delimiter <c>] --from <d> --to <d> [--out <file>]
46
+ blackbox statement --provider csv --file <f> --model-column <name> --amount-column <name> (--unit usd|cents|micro_usd|aed | --currency <CUR> [--rate <usd per unit>] [--rate-date <d>]) [--delimiter <c>] --from <d> --to <d> [--out <file>]
47
47
  blackbox statement --provider custom --connector <file> --from <d> --to <d> [--out <file>]
48
48
  blackbox preflight --require <items> [--optional <items>]
49
49
  blackbox --version
@@ -956,8 +956,29 @@ async function statementAny(provider, flags, from, to) {
956
956
  if (provider === "csv") {
957
957
  const model = flags["model-column"];
958
958
  const amount = flags["amount-column"];
959
- const unit = flags.unit;
959
+ let unit = flags.unit;
960
960
  const delimiter = flags.delimiter;
961
+ // spec/money.md §2c: another currency, at a stated rate or the ECB's for the day
962
+ let fx;
963
+ if (flags.currency) {
964
+ try {
965
+ fx = flags.rate
966
+ ? {
967
+ currency: flags.currency,
968
+ usd_per_unit: flags.rate,
969
+ date: flags["rate-date"] ?? to,
970
+ source: "stated",
971
+ }
972
+ : await money.fxRate(flags.currency, {
973
+ ...(flags["rate-date"] ? { date: flags["rate-date"] } : {}),
974
+ ...(process.env.BLACKBOX_FX_URL ? { url: process.env.BLACKBOX_FX_URL } : {}),
975
+ });
976
+ unit = money.fxUnit(fx.currency, fx.usd_per_unit);
977
+ }
978
+ catch (e) {
979
+ return message(e);
980
+ }
981
+ }
961
982
  if (!flags.file ||
962
983
  !model ||
963
984
  !amount ||
@@ -972,7 +993,7 @@ async function statementAny(provider, flags, from, to) {
972
993
  unit,
973
994
  ...(delimiter ? { delimiter } : {}),
974
995
  });
975
- return { doc: { provider: "csv", from, to, lines: statement.lines } };
996
+ return { doc: { provider: "csv", from, to, lines: statement.lines, ...(fx ? { fx } : {}) } };
976
997
  }
977
998
  catch (e) {
978
999
  return message(e);
@@ -33,6 +33,19 @@ export interface PriceTable {
33
33
  currency: "USD";
34
34
  unit: "micro_usd_per_mtok";
35
35
  models: Record<string, ModelPrice>;
36
+ /** spec/cost.md §1a: percentages added to a call's table price when it matches. */
37
+ uplifts?: Uplift[];
38
+ }
39
+ /** spec/cost.md §1a: a call that matches every condition given costs `percent` more. */
40
+ export interface Uplift {
41
+ /** The configured provider id the call went to (`bedrock-eu`). */
42
+ provider?: string;
43
+ /** A model id prefix (`claude-opus-5`). */
44
+ model?: string;
45
+ /** Flattened usage fields and the values they must have (`{"inference_geo": "us"}`). */
46
+ usage?: Record<string, string>;
47
+ percent: number;
48
+ note?: string;
36
49
  }
37
50
  export interface Tokens {
38
51
  input: number;
@@ -58,6 +71,8 @@ export interface CallCost {
58
71
  service?: string;
59
72
  context_above?: number;
60
73
  };
74
+ /** spec/cost.md §1a: the uplift applied, when one matched. */
75
+ uplift_percent?: number;
61
76
  source?: "reported";
62
77
  }
63
78
  export interface CostReport {
@@ -126,6 +141,9 @@ export declare function priceCall(call: {
126
141
  }, table: PriceTable): Omit<CallCost, "seq" | "model"> & {
127
142
  key: string | null;
128
143
  };
144
+ /** spec/cost.md §1a: the sum of the uplifts a call matches (0 when none): its provider, a model
145
+ * prefix, and usage fields, each only when the uplift names it. */
146
+ export declare function upliftPercent(table: PriceTable, provider: string | null, model: string, usage: Record<string, unknown>): number;
129
147
  /** fils at the CBUAE peg (3.6725 AED / USD), rounded half up. */
130
148
  export declare function toAedFils(microUsd: number): number;
131
149
  export declare function formatAed(fils: number): string;
@@ -40,6 +40,24 @@ export function loadPrices(bytes) {
40
40
  if (p.status !== undefined && p.status !== "deprecated" && p.status !== "beta")
41
41
  throw new Error(`price table: ${model}.status must be deprecated or beta`);
42
42
  }
43
+ if (table.uplifts !== undefined) {
44
+ if (!Array.isArray(table.uplifts))
45
+ throw new Error("price table: uplifts must be a list");
46
+ for (const [i, u] of table.uplifts.entries()) {
47
+ const at = `price table: uplifts[${i}]`;
48
+ if (!Number.isSafeInteger(u?.percent) || u.percent < 1 || u.percent > 1000)
49
+ throw new Error(`${at}.percent must be an integer 1-1000`);
50
+ if (u.provider !== undefined && typeof u.provider !== "string")
51
+ throw new Error(`${at}.provider must be a string`);
52
+ if (u.model !== undefined && typeof u.model !== "string")
53
+ throw new Error(`${at}.model must be a string`);
54
+ if (u.usage !== undefined &&
55
+ (typeof u.usage !== "object" ||
56
+ u.usage === null ||
57
+ Object.values(u.usage).some((v) => typeof v !== "string")))
58
+ throw new Error(`${at}.usage must map usage fields to strings`);
59
+ }
60
+ }
43
61
  return { table, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` };
44
62
  }
45
63
  /** The exact model, else the longest table key the model starts with; null when there's none. */
@@ -194,12 +212,15 @@ export function priceCall(call, table) {
194
212
  const match = call.model === null ? null : priceFor(call.model, table, call.provider);
195
213
  if (match) {
196
214
  const { price, tier } = effectivePrice(match.price, tokens, call.serviceTier);
215
+ const base = callMicroUsd(tokens, price, requests);
216
+ const uplift = upliftPercent(table, call.provider ?? null, call.model, call.usage);
197
217
  return {
198
218
  key: match.key,
199
219
  tokens,
200
- micro_usd: callMicroUsd(tokens, price, requests),
220
+ micro_usd: uplift ? Math.floor((base * (100 + uplift) + 50) / 100) : base,
201
221
  ...(requests ? { requests } : {}),
202
222
  ...(tier ? { tier } : {}),
223
+ ...(uplift ? { uplift_percent: uplift } : {}),
203
224
  };
204
225
  }
205
226
  if (typeof call.reportedMicroUsd === "number")
@@ -212,6 +233,21 @@ export function priceCall(call, table) {
212
233
  };
213
234
  return { key: null, tokens, micro_usd: null, ...(requests ? { requests } : {}) };
214
235
  }
236
+ /** spec/cost.md §1a: the sum of the uplifts a call matches (0 when none): its provider, a model
237
+ * prefix, and usage fields, each only when the uplift names it. */
238
+ export function upliftPercent(table, provider, model, usage) {
239
+ let percent = 0;
240
+ for (const u of table.uplifts ?? []) {
241
+ if (u.provider !== undefined && u.provider !== provider)
242
+ continue;
243
+ if (u.model !== undefined && !model.startsWith(u.model))
244
+ continue;
245
+ if (u.usage && Object.entries(u.usage).some(([k, v]) => String(usage[k] ?? "") !== v))
246
+ continue;
247
+ percent += u.percent;
248
+ }
249
+ return percent;
250
+ }
215
251
  /** fils at the CBUAE peg (3.6725 AED / USD), rounded half up. */
216
252
  export function toAedFils(microUsd) {
217
253
  return Math.floor((microUsd * 36725 + 50_000_000) / 100_000_000);
@@ -45,7 +45,23 @@ export declare function fuel(summary: Summary, budgetMicroUsd: number | null, ba
45
45
  p90_total_micro_usd: number | null;
46
46
  verdict: string;
47
47
  };
48
- export type AmountUnit = "usd" | "cents" | "micro_usd" | "aed";
48
+ /** `fx:<ISO code>:<USD per unit>` (`fx:EUR:1.0821`): another currency at a stated rate (§2c). */
49
+ export type AmountUnit = "usd" | "cents" | "micro_usd" | "aed" | `fx:${string}`;
50
+ /** spec/money.md §2c: the unit for amounts in `currency` at `usdPerUnit` (a decimal string). */
51
+ export declare function fxUnit(currency: string, usdPerUnit: string): AmountUnit;
52
+ export interface FxRate {
53
+ currency: string;
54
+ /** USD per one unit of the currency, as the source gave it (a decimal string). */
55
+ usd_per_unit: string;
56
+ /** The rate's own date (a reference rate is set once a working day). */
57
+ date: string;
58
+ source: string;
59
+ }
60
+ /** spec/money.md §2c: the ECB reference rate for a day (or the latest), through Frankfurter. */
61
+ export declare function fxRate(currency: string, opts?: {
62
+ date?: string;
63
+ url?: string;
64
+ }): Promise<FxRate>;
49
65
  /** spec/money.md §2a-b: an exact decimal amount (string, or a JSON number's text) to integer
50
66
  * micro-USD, rounded half up. `unit` is what the amount counts. */
51
67
  export declare function decimalToMicroUsd(value: unknown, unit: AmountUnit): number;
@@ -112,23 +112,66 @@ const UNITS = {
112
112
  micro_usd: [0, 1n],
113
113
  aed: [10, 36725n],
114
114
  };
115
+ const FX_UNIT = /^fx:([A-Z]{3}):(\d{1,9})(?:\.(\d{1,12}))?$/;
116
+ /** A unit as [power of ten, multiplier, divisor]; null for one that isn't. */
117
+ function unitScale(unit) {
118
+ const s = Object.hasOwn(UNITS, unit) ? UNITS[unit] : undefined;
119
+ if (s)
120
+ return [s[0], 1n, s[1]];
121
+ const m = FX_UNIT.exec(unit);
122
+ if (!m)
123
+ return null;
124
+ const frac = m[3] ?? "";
125
+ const rate = BigInt(`${m[2]}${frac}`);
126
+ return rate === 0n ? null : [6 - frac.length, rate, 1n];
127
+ }
128
+ /** spec/money.md §2c: the unit for amounts in `currency` at `usdPerUnit` (a decimal string). */
129
+ export function fxUnit(currency, usdPerUnit) {
130
+ const unit = `fx:${currency}:${usdPerUnit}`;
131
+ if (!unitScale(unit))
132
+ throw new Error(`not a currency and rate: ${currency} ${usdPerUnit}`);
133
+ return unit;
134
+ }
135
+ /** spec/money.md §2c: the ECB reference rate for a day (or the latest), through Frankfurter. */
136
+ export async function fxRate(currency, opts = {}) {
137
+ if (!/^[A-Z]{3}$/.test(currency))
138
+ throw new Error(`not an ISO 4217 code: ${currency}`);
139
+ if (opts.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(opts.date))
140
+ throw new Error(`not a date: ${opts.date}`);
141
+ const base = (opts.url ?? "https://api.frankfurter.dev").replace(/\/+$/, "");
142
+ const res = await fetch(`${base}/v1/${opts.date ?? "latest"}?base=${currency}&symbols=USD`, {
143
+ signal: AbortSignal.timeout(10_000),
144
+ });
145
+ if (!res.ok)
146
+ throw new Error(`the rate service answered HTTP ${res.status}`);
147
+ const body = (await res.json());
148
+ const usd = body.rates?.USD;
149
+ if (typeof usd !== "number" || !(usd > 0) || typeof body.date !== "string")
150
+ throw new Error("the rate service's answer has no USD rate");
151
+ return {
152
+ currency,
153
+ usd_per_unit: String(usd),
154
+ date: body.date,
155
+ source: "ECB reference rate, via Frankfurter",
156
+ };
157
+ }
115
158
  /** spec/money.md §2a-b: an exact decimal amount (string, or a JSON number's text) to integer
116
159
  * micro-USD, rounded half up. `unit` is what the amount counts. */
117
160
  export function decimalToMicroUsd(value, unit) {
118
161
  const text = typeof value === "number" && Number.isFinite(value) ? String(value) : value;
119
162
  const m = typeof text === "string" ? DECIMAL.exec(text.trim()) : null;
120
- const scale = UNITS[unit];
163
+ const scale = unitScale(unit);
121
164
  if (!m || !scale)
122
165
  throw new Error(`not a non-negative decimal amount: ${JSON.stringify(value)}`);
123
166
  const frac = m[2] ?? "";
124
- const digits = BigInt(`${m[1]}${frac}`);
167
+ const digits = BigInt(`${m[1]}${frac}`) * scale[1];
125
168
  if (digits === 0n)
126
169
  return 0;
127
170
  const k = Number(m[3] ?? 0) - frac.length + scale[0];
128
171
  if (k > 30)
129
172
  throw new Error(`amount too large: ${JSON.stringify(value)}`);
130
173
  const num = k >= 0 ? digits * 10n ** BigInt(k) : digits;
131
- const den = scale[1] * (k >= 0 ? 1n : 10n ** BigInt(-k));
174
+ const den = scale[2] * (k >= 0 ? 1n : 10n ** BigInt(-k));
132
175
  let micro = num / den;
133
176
  if ((num % den) * 2n >= den)
134
177
  micro += 1n;
@@ -312,8 +355,8 @@ export function checkConnector(value) {
312
355
  for (const k of ["results", "model", "amount"])
313
356
  if (typeof c[k] !== "string" || !PATH.test(c[k]))
314
357
  return `${k} must be a path`;
315
- if (!c.unit || !(c.unit in UNITS))
316
- return "unit must be usd, cents, micro_usd or aed";
358
+ if (!c.unit || !unitScale(c.unit))
359
+ return "unit must be usd, cents, micro_usd, aed or fx:<CUR>:<USD per unit>";
317
360
  if (c.currency !== undefined &&
318
361
  (typeof c.currency?.path !== "string" ||
319
362
  !PATH.test(c.currency.path) ||
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.13.0";
1
+ export declare const VERSION = "0.14.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The SDK version, on its own so the CLI can print it without loading the whole SDK.
2
- export const VERSION = "0.13.0";
2
+ export const VERSION = "0.14.0";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zanii/blackbox",
3
3
  "license": "Apache-2.0",
4
- "version": "0.13.0",
4
+ "version": "0.14.0",
5
5
  "description": "The flight recorder for AI agents: sessions, a zero-loss spool, framework hooks, approvals, and offline verification of the gateway's hash-chained record.",
6
6
  "keywords": [
7
7
  "ai-agents",