summaryrefslogtreecommitdiff
path: root/src/pricing_format.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-15 22:11:53 -0600
committert <t@tjp.lol>2026-06-16 11:17:14 -0600
commitf8c6d45755acb5abf9ac68931268b7945da0fae0 (patch)
treea88ed7edd4aa3e5acced9ff99ac61b7f36b267a6 /src/pricing_format.zig
parent8206642d3a1736aaf928a5b9a732e747f5294228 (diff)
display fixes: markdown rendering, tool-specific components
Diffstat (limited to 'src/pricing_format.zig')
-rw-r--r--src/pricing_format.zig214
1 files changed, 214 insertions, 0 deletions
diff --git a/src/pricing_format.zig b/src/pricing_format.zig
new file mode 100644
index 0000000..daceff9
--- /dev/null
+++ b/src/pricing_format.zig
@@ -0,0 +1,214 @@
+//! Display formatters for `panto.Pricing` and accumulated session cost.
+//!
+//! libpanto stores prices as micro-cents per token; the human-friendly
+//! unit is USD per million tokens (e.g. "3.0"). The formatters here
+//! convert the integer representation to the display form, with two
+//! shapes:
+//!
+//! - `formatPriceCompact` — the dense `"1i/5o/0.1r/1.25w"` form
+//! used in the model picker, where the four rates are the only
+//! content and the user reads them in a single glance.
+//! - `formatCostDollars` — the canonical dollar amount
+//! (`"$0.06"`, `"$1.23"`), used in the footer session total.
+//!
+//! The two have to make a few choices consistently:
+//!
+//! - Trailing zeros are dropped: "$3.00/Mtok" prints as `"3"`, not
+//! `"3.00"`, and the compact form does the same.
+//! - Sub-cent values round to the nearest cent, not to 0 or to a
+//! half-cent (so `$0.005` becomes `$0.01`, matching what a bank
+//! would say).
+//!
+//! The compact form omits any category whose price is `null` (unknown).
+//! When ALL categories are unknown, the result is `null` and the
+//! caller renders the bare model id (no price tag).
+
+const std = @import("std");
+const panto = @import("panto");
+
+const Pricing = panto.Pricing;
+
+/// Suffixes for the compact form, in field order: input, output,
+/// cache read, cache write. Known zeros still print (the
+/// `cache_write = 0` convention for OpenAI), so the slot's presence
+/// is determined by the field, not by its value.
+const SUFFIXES = [_]u8{ 'i', 'o', 'r', 'w' };
+
+/// The user-facing compact form for a single (provider, model) rate
+/// set, e.g. "1i/5o/0.1r/1.25w". Returns `null` when EVERY field is
+/// `null` (no pricing known) so the caller can fall back to a
+/// model-only label without showing a dangling `?/?/?/?`.
+///
+/// Allocation: caller-owned, written into `buf`. The 64-byte buffer
+/// is more than enough — the worst case is four fields of up to
+/// eight digits plus three slashes plus four suffix bytes.
+pub fn formatPriceCompact(pricing: Pricing, buf: []u8) ?[]const u8 {
+ var out_len: usize = 0;
+ var emitted: usize = 0;
+
+ const fields = [_]?u64{ pricing.input, pricing.output, pricing.cache_read, pricing.cache_write };
+ var i: usize = 0;
+ while (i < 4) : (i += 1) {
+ const v = fields[i] orelse continue;
+ if (emitted != 0) {
+ if (out_len >= buf.len) return null;
+ buf[out_len] = '/';
+ out_len += 1;
+ }
+ var scratch: [16]u8 = undefined;
+ const text = formatRate(v, &scratch);
+ if (out_len + text.len + 1 > buf.len) return null;
+ @memcpy(buf[out_len .. out_len + text.len], text);
+ out_len += text.len;
+ buf[out_len] = SUFFIXES[i];
+ out_len += 1;
+ emitted += 1;
+ }
+ if (emitted == 0) return null;
+ return buf[0..out_len];
+}
+
+/// Format a single micro-cents-per-token value as a human price per
+/// million tokens, e.g. 300 -> "3", 30 -> "0.3", 375 -> "3.75",
+/// 5 -> "0.05". Strips trailing zeros (and a trailing decimal
+/// point) so the result is the shortest accurate representation.
+fn formatRate(micro_cents_per_token: u64, buf: []u8) []const u8 {
+ // We work with the integer directly: `value / 100` is whole
+ // dollars; `value % 100` is the cents fraction. Render the
+ // cents, then drop trailing zeros (and the dot if all zeros).
+ const whole = micro_cents_per_token / 100;
+ const frac = micro_cents_per_token % 100;
+
+ // 16-byte buffer is more than enough for "99999999.99" (11 chars).
+ var w: usize = 0;
+ const whole_text = std.fmt.bufPrint(buf[w..], "{d}", .{whole}) catch return buf[0..0];
+ w += whole_text.len;
+ if (frac != 0) {
+ buf[w] = '.';
+ w += 1;
+ // Two-digit cents with a leading zero, e.g. 5 -> "05".
+ const cents_text = std.fmt.bufPrint(buf[w..], "{d:0>2}", .{frac}) catch return buf[0..0];
+ w += cents_text.len;
+ // Drop trailing zeros: "0.30" -> "0.3", "0.05" stays.
+ while (w > 0 and buf[w - 1] == '0') w -= 1;
+ // If we dropped the last zero, also drop the dot.
+ if (w > 0 and buf[w - 1] == '.') w -= 1;
+ }
+ return buf[0..w];
+}
+
+/// Format an accumulated micro-cents total as a dollar amount
+/// `"$X.YY"`. Sub-cent values round to the nearest cent; the
+/// (rare) case where cents rounding overflows 100 folds to the
+/// next dollar.
+pub fn formatCostDollars(micro_cents_total: ?u64, buf: []u8) []const u8 {
+ const total = micro_cents_total orelse return buf[0..0];
+ // 1 dollar = 100 cents = 100_000_000 micro-cents. Divide into
+ // whole dollars and a cents residue, then round the cents to
+ // the nearest cent. Pure integer arithmetic — no float drift.
+ const total_cents = total / 1_000_000; // 0.01 USD = 1 cent
+ const rounding = (total % 1_000_000) / 500_000; // 0 -> no round, 1 -> round up
+ const cents_total = total_cents + rounding;
+ const dollars = cents_total / 100;
+ const cents = cents_total % 100;
+ return std.fmt.bufPrint(buf, "${d}.{d:0>2}", .{ dollars, cents }) catch buf[0..0];
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+const testing = std.testing;
+
+test "formatPriceCompact: shows all four rates with minimal digits" {
+ var buf: [64]u8 = undefined;
+ const p: Pricing = .{ .input = 100, .output = 500, .cache_read = 10, .cache_write = 125 };
+ const got = formatPriceCompact(p, &buf).?;
+ // 100 micro-cents/token = $1.00/Mtok -> "1i"
+ // 500 -> $5.00 -> "5o"
+ // 10 -> $0.10 -> "0.1r"
+ // 125 -> $1.25 -> "1.25w"
+ try testing.expectEqualStrings("1i/5o/0.1r/1.25w", got);
+}
+
+test "formatPriceCompact: omits unknown fields" {
+ var buf: [64]u8 = undefined;
+ const partial: Pricing = .{ .input = 300, .output = 1500 };
+ const got = formatPriceCompact(partial, &buf).?;
+ try testing.expectEqualStrings("3i/15o", got);
+}
+
+test "formatPriceCompact: all-unknown -> null" {
+ var buf: [64]u8 = undefined;
+ try testing.expect(formatPriceCompact(.{ .input = null, .output = null, .cache_read = null, .cache_write = null }, &buf) == null);
+}
+
+test "formatPriceCompact: explicit zero prints as 0" {
+ // OpenAI's cache_write is a known zero, not unknown — must surface as
+ // "0w" rather than being silently dropped.
+ var buf: [64]u8 = undefined;
+ const p: Pricing = .{ .input = 250, .output = 1000, .cache_read = 125, .cache_write = 0 };
+ const got = formatPriceCompact(p, &buf).?;
+ try testing.expectEqualStrings("2.5i/10o/1.25r/0w", got);
+}
+
+test "formatPriceCompact: integer rates strip trailing .00" {
+ var buf: [64]u8 = undefined;
+ const p: Pricing = .{ .input = 300, .output = 1500 };
+ const got = formatPriceCompact(p, &buf).?;
+ try testing.expectEqualStrings("3i/15o", got);
+ // No spurious ".00".
+ try testing.expect(std.mem.indexOf(u8, got, ".00") == null);
+}
+
+test "formatPriceCompact: sub-cent rate keeps leading zero" {
+ // 0.05 -> "0.05" (not ".05" or "5e-2").
+ var buf: [64]u8 = undefined;
+ const p: Pricing = .{ .input = 5 };
+ const got = formatPriceCompact(p, &buf).?;
+ try testing.expectEqualStrings("0.05i", got);
+}
+
+test "formatPriceCompact: sub-cent with trailing zero drops it" {
+ // 0.10 -> "0.1" (the trailing zero is dropped, but the leading 0 stays).
+ var buf: [64]u8 = undefined;
+ const p: Pricing = .{ .cache_read = 10 };
+ const got = formatPriceCompact(p, &buf).?;
+ try testing.expectEqualStrings("0.1r", got);
+}
+
+test "formatPriceCompact: example given in the spec (haiku 4.5)" {
+ // 1i/5o/0.1r/1.25w per the user's example.
+ var buf: [64]u8 = undefined;
+ const p: Pricing = .{ .input = 100, .output = 500, .cache_read = 10, .cache_write = 125 };
+ const got = formatPriceCompact(p, &buf).?;
+ try testing.expectEqualStrings("1i/5o/0.1r/1.25w", got);
+}
+
+test "formatCostDollars: zero, sub-cent, whole-dollar, mixed" {
+ var buf: [32]u8 = undefined;
+ try testing.expectEqualStrings("$0.00", formatCostDollars(0, &buf));
+ // 100_000 micro-cents = $0.001 -> rounded to nearest cent = $0.00.
+ // (The footer uses "X.YY" form; sub-cent values round.)
+ try testing.expectEqualStrings("$0.00", formatCostDollars(100_000, &buf));
+ // 600_000 micro-cents = $0.006 -> $0.01.
+ try testing.expectEqualStrings("$0.01", formatCostDollars(600_000, &buf));
+ // 6_000_000 micro-cents = $0.06 exactly.
+ try testing.expectEqualStrings("$0.06", formatCostDollars(6_000_000, &buf));
+ // 100_000_000 micro-cents = $1.00.
+ try testing.expectEqualStrings("$1.00", formatCostDollars(100_000_000, &buf));
+ // 123_450_000 micro-cents = $1.2345 -> $1.23.
+ try testing.expectEqualStrings("$1.23", formatCostDollars(123_450_000, &buf));
+}
+
+test "formatCostDollars: cent-rounding overflow folds to next dollar" {
+ // $0.9995 rounds to $1.00; verify the overflow path that nudges the
+ // dollars counter.
+ var buf: [32]u8 = undefined;
+ try testing.expectEqualStrings("$1.00", formatCostDollars(99_950_000, &buf));
+}
+
+test "formatCostDollars: null total returns empty" {
+ var buf: [32]u8 = undefined;
+ try testing.expectEqualStrings("", formatCostDollars(null, &buf));
+}