From 621d7fee0ace729f8d44126032d2c6e13f72ee7f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 7 Jul 2026 11:29:23 -0600 Subject: Move libpanto projects to libpantograph dependency Remove the in-repo libpanto sources and binding projects from pantograph. Consume libpantograph through the Zig package URL at code.tjp.lol/libpantograph.git, including the Lua module artifact used by CLI extensions. --- libpanto/src/pricing.zig | 383 ----------------------------------------------- 1 file changed, 383 deletions(-) delete mode 100644 libpanto/src/pricing.zig (limited to 'libpanto/src/pricing.zig') diff --git a/libpanto/src/pricing.zig b/libpanto/src/pricing.zig deleted file mode 100644 index 0565593..0000000 --- a/libpanto/src/pricing.zig +++ /dev/null @@ -1,383 +0,0 @@ -//! 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; -} - -/// Add a turn's cost to a running session total, in micro-cents. -/// Saturating `+%` on the total (a session-bucket overflow is -/// catastrophic for a `u64` value, so we pin to max rather than wrap). -/// The poison rule matches `costMicroCents`: any individual turn -/// whose cost is unknown poisons the session total to `null`. -/// -/// This is the single accumulation point for session-level cost -/// display, so the model-switch tolerance lives here: a switch in the -/// middle of a session just means successive `costMicroCents` calls -/// run against different `Pricing` structs (one per `(provider, -/// model)`). The TUI footer's cost pass holds the registry and looks -/// up the right pricing for each turn at the time the turn lands. -pub fn addCost(total: ?u64, turn_cost: ?u64) ?u64 { - const t = total orelse return null; - const c = turn_cost orelse return null; - return t +% c; -} - -test "addCost: accumulates known costs across many turns" { - var s: ?u64 = 0; - s = addCost(s, 1_000_000); // $0.01 - s = addCost(s, 5_000_000); // $0.05 - s = addCost(s, 2_000_000); // $0.02 - try testing.expectEqual(@as(?u64, 8_000_000), s); -} - -test "addCost: a known + unknown + known sequence poisons the total" { - // The poison rule is one-way: once any priced component of any - // turn is unknown, the whole session cost is unknown forever. - var s: ?u64 = 0; - s = addCost(s, 1_000_000); // $0.01 (known) - try testing.expectEqual(@as(?u64, 1_000_000), s); - s = addCost(s, null); // poison! - try testing.expect(s == null); - s = addCost(s, 5_000_000); // still null - try testing.expect(s == null); -} - -test "addCost: tolerates a model switch mid-session (each turn's cost is per-model)" { - // A model switch in the middle of a session: each turn's - // cost is computed against the active model's pricing - // upstream of `addCost`, so the function itself just sees a - // sequence of independent turn costs. The TUI's session-cost - // display sums them all; this is the tolerance: a switch is - // invisible to the accumulator as long as both models have - // pricing entries. - var s: ?u64 = 0; - s = addCost(s, costMicroCents( - .{ .input = 100, .output = 50 }, - .{ .input = 300, .output = 1500 }, - ).?); - // Switch to a different-priced model. - s = addCost(s, costMicroCents( - .{ .input = 200, .output = 100 }, - .{ .input = 100, .output = 500 }, - ).?); - // 100*300 + 50*1500 = 105_000 - // 200*100 + 100*500 = 70_000 - // total = 175_000 micro-cents = $0.00175 -> $0.00 (rounded) - try testing.expectEqual(@as(?u64, 175_000), s); -} - -test "addCost: a switch from a priced model to an unpriced model poisons" { - // The new model has no pricing entry: `costMicroCents` returns - // null (every priced component is null), and `addCost` then - // poisons the session total to null. The cost UP TO the - // switch is "known"; after it the total is "unknown". - var s: ?u64 = 0; - s = addCost(s, costMicroCents( - .{ .input = 100, .output = 50 }, - .{ .input = 300, .output = 1500 }, - ).?); - // Switch to an unpriced model. - s = addCost(s, costMicroCents( - .{ .input = 200, .output = 100 }, - .{}, // no pricing at all - )); - try testing.expect(s == null); -} - -// ============================================================================= -// 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); -} -- cgit v1.3