diff options
Diffstat (limited to 'libpanto/src/pricing.zig')
| -rw-r--r-- | libpanto/src/pricing.zig | 303 |
1 files changed, 303 insertions, 0 deletions
diff --git a/libpanto/src/pricing.zig b/libpanto/src/pricing.zig new file mode 100644 index 0000000..7d4e1f8 --- /dev/null +++ b/libpanto/src/pricing.zig @@ -0,0 +1,303 @@ +//! Per-(provider, model) token pricing and cost calculation. +//! +//! Prices are stored as integers — specifically, *micro-cents per token* +//! (1/1,000,000 of a cent), equivalently picodollars (10^-12 USD) per +//! token. Reasons: +//! +//! - All commonly-quoted "USD per million tokens" prices land on round +//! integers under the conversion: $3.00 / 1M tokens = 300 +//! micro-cents per token. +//! - Per-message token counts (10^2 to 10^5) multiplied by per-token +//! prices (~10^2) stay well inside `u64` for entire long-running +//! sessions — a session worth ~$1 (10^14 micro-cents) is nowhere +//! near `u64.max` (~1.8 × 10^19). +//! - Summing per-turn costs across a session is exact: no +//! floating-point drift. +//! +//! The TOML on-disk format lets users write `input = 3.0` for $3/Mtok, +//! which is the natural unit for humans. The loader multiplies by 100 +//! and rounds to the nearest integer; the rounding handles parse-time +//! float-precision wobble. +//! +//! `costMicroCents` does the integer arithmetic. Each `Pricing` field +//! is `?u64`: `null` means "we don't know the price for this token +//! category," which is distinct from a price of zero (e.g. OpenAI does +//! charge nothing for cache writes — that's a known 0, not unknown). +//! `costMicroCents` returns `?u64`: if any usage category with nonzero +//! count maps to a `null` price, the whole turn cost goes to `null` +//! ("unknown"), and any session-level sum that includes that turn must +//! likewise degenerate to `null`. This prevents silently treating +//! unknown costs as free. +//! +//! The display layer lives in the CLI; libpanto only computes. + +const std = @import("std"); + +const session_mod = @import("session.zig"); +pub const Usage = session_mod.Usage; + +// ============================================================================= +// Pricing struct +// ============================================================================= + +/// Per-token pricing for a single (provider, model) pair. +/// +/// Units: micro-cents per token (1/1,000,000 of a cent per token), aka +/// picodollars (10^-12 USD) per token. +/// +/// Conversion from "USD per million tokens": +/// +/// micro_cents_per_token = round(USD_per_Mtok * 100) +/// +/// So $3.00/Mtok → 300 micro-cents/token. +pub const Pricing = struct { + /// Per fresh (uncached, non-cache-written) input token. `null` = + /// unknown (e.g. field omitted from `models.toml`). + input: ?u64 = null, + /// Per output token. `null` = unknown. + output: ?u64 = null, + /// Per cache-read input token. Typically a fraction of `input` + /// (0.1× on Anthropic, 0.5× on OpenAI for cached prompt tokens). + /// `null` = unknown. + cache_read: ?u64 = null, + /// Per cache-write input token. Anthropic charges a premium + /// (1.25× `input`); OpenAI doesn't bill a cache-write rate (a + /// known 0, which should be written `cache_write = 0` rather than + /// omitted). `null` = unknown. + cache_write: ?u64 = null, + + /// Convert a USD-per-million-tokens float (the human-friendly unit) + /// to the internal integer representation. Rounds to nearest. + /// + /// `dollars_per_mtok * 1_000_000 cents/dollar / 1_000_000 tokens` = + /// cents-per-token, then * 1_000_000 micro-cents/cent = + /// micro-cents-per-token. The 1_000_000s cancel, leaving + /// `dollars_per_mtok * 100`. + /// + /// Non-finite inputs round to 0. Negative inputs are clamped to 0 + /// (treated as a known free price, not unknown). + pub fn fromDollarsPerMtok(dollars_per_mtok: f64) u64 { + if (!std.math.isFinite(dollars_per_mtok) or dollars_per_mtok <= 0) return 0; + const scaled = dollars_per_mtok * 100.0; + const r = @round(scaled); + if (r >= @as(f64, @floatFromInt(std.math.maxInt(u64)))) return std.math.maxInt(u64); + return @intFromFloat(r); + } +}; + +// ============================================================================= +// Cost calculation +// ============================================================================= + +/// Compute the cost of a single turn's `usage` under the given `pricing`, +/// in micro-cents. Returns `null` if any usage category with a nonzero +/// token count maps to a `null` price — i.e. "we used some cache reads +/// but we don't know the cache-read price" poisons the whole turn cost +/// to "unknown". Categories with zero tokens are ignored regardless of +/// whether their price is known, so e.g. a model with `cache_write = +/// null` still produces a known cost on turns that never write to +/// cache. +/// +/// `reasoning` does NOT contribute separately — it's already counted +/// inside `output`. +pub fn costMicroCents(usage: Usage, pricing: Pricing) ?u64 { + var total: u64 = 0; + total +%= component(usage.input, pricing.input) orelse return null; + total +%= component(usage.output, pricing.output) orelse return null; + total +%= component(usage.cache_read, pricing.cache_read) orelse return null; + total +%= component(usage.cache_write, pricing.cache_write) orelse return null; + return total; +} + +/// Cost contribution (in micro-cents) from a single (tokens, price) +/// pair. Returns `0` when `tokens == 0` regardless of whether `price` +/// is known — so unknown prices don't poison turns that never used +/// that category. Returns `null` when tokens are nonzero but the +/// price is unknown; callers convert that to a `null` total. +fn component(tokens: u64, price: ?u64) ?u64 { + if (tokens == 0) return 0; + const p = price orelse return null; + return tokens *% p; +} + +// ============================================================================= +// Registry +// ============================================================================= + +/// In-memory registry of `(provider, model) -> Pricing`. Lookups are by +/// exact match of both fields. +/// +/// The registry owns the (provider, model) key strings; entries are +/// pushed via `set` (which duplicates the inputs). +pub const Registry = struct { + allocator: std.mem.Allocator, + entries: std.ArrayList(Entry), + + pub const Entry = struct { + provider: []u8, + model: []u8, + pricing: Pricing, + }; + + pub fn init(allocator: std.mem.Allocator) Registry { + return .{ .allocator = allocator, .entries = .empty }; + } + + pub fn deinit(self: *Registry) void { + for (self.entries.items) |e| { + self.allocator.free(e.provider); + self.allocator.free(e.model); + } + self.entries.deinit(self.allocator); + } + + /// Look up pricing for (provider, model). Returns null if no entry + /// matches — distinct from "price is zero," which is a valid entry. + pub fn get(self: *const Registry, provider: []const u8, model: []const u8) ?Pricing { + for (self.entries.items) |e| { + if (std.mem.eql(u8, e.provider, provider) and std.mem.eql(u8, e.model, model)) { + return e.pricing; + } + } + return null; + } + + /// Insert or replace pricing for (provider, model). Duplicates the + /// key strings. + pub fn set( + self: *Registry, + provider: []const u8, + model: []const u8, + pricing: Pricing, + ) !void { + for (self.entries.items) |*e| { + if (std.mem.eql(u8, e.provider, provider) and std.mem.eql(u8, e.model, model)) { + e.pricing = pricing; + return; + } + } + const provider_copy = try self.allocator.dupe(u8, provider); + errdefer self.allocator.free(provider_copy); + const model_copy = try self.allocator.dupe(u8, model); + errdefer self.allocator.free(model_copy); + try self.entries.append(self.allocator, .{ + .provider = provider_copy, + .model = model_copy, + .pricing = pricing, + }); + } + + pub fn count(self: *const Registry) usize { + return self.entries.items.len; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +const testing = std.testing; + +test "Pricing.fromDollarsPerMtok: $3.00/Mtok -> 300 micro-cents/token" { + try testing.expectEqual(@as(u64, 300), Pricing.fromDollarsPerMtok(3.0)); + try testing.expectEqual(@as(u64, 1500), Pricing.fromDollarsPerMtok(15.0)); + try testing.expectEqual(@as(u64, 30), Pricing.fromDollarsPerMtok(0.3)); + try testing.expectEqual(@as(u64, 0), Pricing.fromDollarsPerMtok(0)); + try testing.expectEqual(@as(u64, 0), Pricing.fromDollarsPerMtok(-1)); +} + +test "Pricing.fromDollarsPerMtok: rounds float noise to clean integer" { + // 0.1 * 3 = 0.30000000000000004 in IEEE 754. Verify rounding + // recovers the clean integer. + const v = 0.1 * 3.0; + try testing.expectEqual(@as(u64, 30), Pricing.fromDollarsPerMtok(v)); +} + +test "costMicroCents: standard mixed input/output" { + const pricing: Pricing = .{ + .input = 300, // $3/Mtok + .output = 1500, // $15/Mtok + }; + const usage: Usage = .{ .input = 1000, .output = 200 }; + // 1000*300 + 200*1500 = 300_000 + 300_000 = 600_000 micro-cents. + // = 6 cents = $0.06. + try testing.expectEqual(@as(?u64, 600_000), costMicroCents(usage, pricing)); +} + +test "costMicroCents: cache_read and cache_write discounts are honored" { + const pricing: Pricing = .{ + .input = 300, + .output = 1500, + .cache_read = 30, // 0.1x input + .cache_write = 375, // 1.25x input + }; + const usage: Usage = .{ + .input = 1000, + .output = 200, + .cache_read = 5000, + .cache_write = 500, + }; + // 1000*300 + 200*1500 + 5000*30 + 500*375 + // = 300_000 + 300_000 + 150_000 + 187_500 = 937_500. + try testing.expectEqual(@as(?u64, 937_500), costMicroCents(usage, pricing)); +} + +test "costMicroCents: reasoning tokens do not double-count" { + const pricing: Pricing = .{ .input = 0, .output = 1500 }; + const usage: Usage = .{ .output = 100, .reasoning = 60 }; + // Cost is from `output` alone; reasoning is a subset of output. + try testing.expectEqual(@as(?u64, 150_000), costMicroCents(usage, pricing)); +} + +test "costMicroCents: unknown price + nonzero usage poisons to null" { + // gpt-4o written with only input/output set; cache fields default + // to null (unknown). A turn that uses any cache reads should + // surface as unknown cost, not silently free. + const pricing: Pricing = .{ + .input = 250, + .output = 1000, + // cache_read, cache_write left null. + }; + const usage: Usage = .{ .input = 1000, .output = 200, .cache_read = 500 }; + try testing.expectEqual(@as(?u64, null), costMicroCents(usage, pricing)); +} + +test "costMicroCents: unknown price + zero usage stays known" { + // Same partially-specified pricing, but the turn never touched + // cache. Cost should remain known. + const pricing: Pricing = .{ .input = 250, .output = 1000 }; + const usage: Usage = .{ .input = 1000, .output = 200 }; + try testing.expectEqual(@as(?u64, 450_000), costMicroCents(usage, pricing)); +} + +test "Registry: set, get, replace" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + + try testing.expect(reg.get("anthropic", "claude-sonnet-4") == null); + + try reg.set("anthropic", "claude-sonnet-4", .{ .input = 300, .output = 1500 }); + try testing.expectEqual(@as(usize, 1), reg.count()); + + const p = reg.get("anthropic", "claude-sonnet-4").?; + try testing.expectEqual(@as(?u64, 300), p.input); + try testing.expectEqual(@as(?u64, 1500), p.output); + + // Replace existing. + try reg.set("anthropic", "claude-sonnet-4", .{ .input = 400, .output = 1600 }); + try testing.expectEqual(@as(usize, 1), reg.count()); + const p2 = reg.get("anthropic", "claude-sonnet-4").?; + try testing.expectEqual(@as(?u64, 400), p2.input); +} + +test "Registry: distinct (provider, model) pairs" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + try reg.set("openai", "gpt-4o", .{ .input = 250 }); + try reg.set("openai", "gpt-4o-mini", .{ .input = 15 }); + try reg.set("anthropic", "claude-sonnet-4", .{ .input = 300 }); + try testing.expectEqual(@as(usize, 3), reg.count()); + try testing.expectEqual(@as(?u64, 250), reg.get("openai", "gpt-4o").?.input); + try testing.expectEqual(@as(?u64, 15), reg.get("openai", "gpt-4o-mini").?.input); + try testing.expectEqual(@as(?u64, 300), reg.get("anthropic", "claude-sonnet-4").?.input); +} |
