summaryrefslogtreecommitdiff
path: root/src/pricing_format.zig
blob: daceff9be26a4a195a0e3b1b256a1365ace1c029 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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));
}