summaryrefslogtreecommitdiff
path: root/libpanto/src/pricing.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/pricing.zig')
-rw-r--r--libpanto/src/pricing.zig303
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);
+}