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
215
216
217
218
219
220
//! Operator-declared model pricing.
//!
//! The agent loop prices every assistant message that its adapter left
//! unpriced, using the compiled model catalog (see
//! [`price_assistant_message`](crate::price_assistant_message)). The catalog
//! only knows about models shipped with the crate, so local endpoints, private
//! deployments, and negotiated per-tier rates all price at zero.
//!
//! This module is the escape hatch: a host binary can declare its own rates and
//! hand them to the loop via
//! [`AgentOptions::with_cost_calculator`](crate::AgentOptions::with_cost_calculator)
//! or [`AgentOptions::with_pricing_table`](crate::AgentOptions::with_pricing_table).
//!
//! # Precedence
//!
//! For each assistant message the loop resolves cost in this order:
//!
//! 1. **The adapter's own cost**, when non-zero. Only the proxy adapter reports
//! real provider-billed amounts, and those always win.
//! 2. **The operator-declared [`CostCalculator`]**, when one is configured and
//! it returns a non-zero [`Cost`] for the model.
//! 3. **The compiled model catalog**.
//!
//! A message stays at zero only when all three decline.
//!
//! # Example
//!
//! ```rust
//! use swink_agent::{ModelRates, PricingTable, CostCalculator, Usage};
//!
//! let table = PricingTable::new().with_model(
//! "my-local-llama",
//! ModelRates::default()
//! .with_input_per_million(0.10)
//! .with_output_per_million(0.40),
//! );
//!
//! let usage = Usage::default().with_input(1_000_000).with_output(1_000_000);
//! let cost = table.calculate("my-local-llama", &usage).unwrap();
//! assert!((cost.total - 0.50).abs() < 1e-9);
//! ```
use std::collections::HashMap;
use serde::Deserialize;
use crate::types::{Cost, Usage};
/// Per-model rates, expressed in USD per million tokens.
///
/// Deserializes from a TOML table so operators can declare rates in a config
/// file:
///
/// ```toml
/// [pricing."my-local-llama"]
/// input_per_million = 0.10
/// output_per_million = 0.40
/// ```
///
/// Unset categories default to `0.0`, which means "this category is free",
/// not "fall back to the catalog". Fallback happens per-model, not per-field:
/// see the module-level precedence rules.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct ModelRates {
/// USD per million input (prompt) tokens.
pub input_per_million: f64,
/// USD per million output (completion) tokens.
pub output_per_million: f64,
/// USD per million tokens read from a provider-side prompt cache.
pub cache_read_per_million: f64,
/// USD per million tokens written to a provider-side prompt cache.
pub cache_write_per_million: f64,
}
impl ModelRates {
/// Set the USD-per-million-input-token rate.
#[must_use]
pub const fn with_input_per_million(mut self, rate: f64) -> Self {
self.input_per_million = rate;
self
}
/// Set the USD-per-million-output-token rate.
#[must_use]
pub const fn with_output_per_million(mut self, rate: f64) -> Self {
self.output_per_million = rate;
self
}
/// Set the USD-per-million-cache-read-token rate.
#[must_use]
pub const fn with_cache_read_per_million(mut self, rate: f64) -> Self {
self.cache_read_per_million = rate;
self
}
/// Set the USD-per-million-cache-write-token rate.
#[must_use]
pub const fn with_cache_write_per_million(mut self, rate: f64) -> Self {
self.cache_write_per_million = rate;
self
}
/// Price a [`Usage`] with these rates.
#[must_use]
pub fn cost_for(&self, usage: &Usage) -> Cost {
#[allow(clippy::cast_precision_loss)] // token counts fit comfortably in f64
let per_m = |tokens: u64, rate: f64| -> f64 { tokens as f64 * rate / 1_000_000.0 };
let input = per_m(usage.input, self.input_per_million);
let output = per_m(usage.output, self.output_per_million);
let cache_read = per_m(usage.cache_read, self.cache_read_per_million);
let cache_write = per_m(usage.cache_write, self.cache_write_per_million);
Cost {
input,
output,
cache_read,
cache_write,
total: input + output + cache_read + cache_write,
..Cost::default()
}
}
}
/// Resolves a [`Cost`] for a model's token [`Usage`].
///
/// Implemented by [`PricingTable`], and blanket-implemented for any
/// `Fn(&str, &Usage) -> Option<Cost>` closure, so hosts can supply either a
/// declarative rate table or arbitrary logic (tiered rates, per-tenant
/// markups, a rates service).
///
/// Return `None` — or a zero [`Cost`] — to decline pricing and let the loop
/// fall back to the compiled model catalog.
pub trait CostCalculator: Send + Sync {
/// Price `usage` for `model_id`, or return `None` to decline.
fn calculate(&self, model_id: &str, usage: &Usage) -> Option<Cost>;
}
impl<F> CostCalculator for F
where
F: Fn(&str, &Usage) -> Option<Cost> + Send + Sync,
{
fn calculate(&self, model_id: &str, usage: &Usage) -> Option<Cost> {
self(model_id, usage)
}
}
/// An operator-declared table of per-model rates, keyed by model ID.
///
/// Deserializes transparently from a TOML table of tables, so a `[pricing]`
/// config section maps straight onto it. See [`ModelRates`] for the per-model
/// shape and the [module docs](self) for how the table interacts with catalog
/// pricing.
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(transparent)]
pub struct PricingTable {
rates: HashMap<String, ModelRates>,
}
impl PricingTable {
/// Create an empty table.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Declare rates for a model ID, replacing any previous entry.
#[must_use]
pub fn with_model(mut self, model_id: impl Into<String>, rates: ModelRates) -> Self {
self.rates.insert(model_id.into(), rates);
self
}
/// Look up the declared rates for a model ID.
#[must_use]
pub fn get(&self, model_id: &str) -> Option<&ModelRates> {
self.rates.get(model_id)
}
/// Number of models with declared rates.
#[must_use]
pub fn len(&self) -> usize {
self.rates.len()
}
/// Whether any rates are declared.
///
/// An empty table declines every model, so the loop falls back to the
/// catalog for all of them.
#[must_use]
pub fn is_empty(&self) -> bool {
self.rates.is_empty()
}
/// Iterate over the declared `(model_id, rates)` pairs.
pub fn iter(&self) -> impl Iterator<Item = (&str, &ModelRates)> {
self.rates.iter().map(|(id, rates)| (id.as_str(), rates))
}
}
impl CostCalculator for PricingTable {
fn calculate(&self, model_id: &str, usage: &Usage) -> Option<Cost> {
self.rates.get(model_id).map(|rates| rates.cost_for(usage))
}
}
impl FromIterator<(String, ModelRates)> for PricingTable {
fn from_iter<I: IntoIterator<Item = (String, ModelRates)>>(iter: I) -> Self {
Self {
rates: iter.into_iter().collect(),
}
}
}
#[cfg(test)]
#[path = "pricing_tests.rs"]
mod tests;