agent_sdk/pricing.rs
1//! Pricing seam for run-level cost budgets.
2//!
3//! [`UsageLimits::max_cost_usd`](crate::types::UsageLimits::max_cost_usd)
4//! stops a run once its estimated spend crosses a threshold, which requires
5//! turning token counts into dollars. Out of the box the SDK prices from the
6//! static [`model_capabilities`](crate::model_capabilities) table compiled
7//! into the binary: a model that table has never heard of estimates to `None`
8//! ("cost unknown"), accrues nothing, and can never trip the limit.
9//!
10//! A [`CostEstimator`] plugs a richer pricing source in front of that table.
11//! Wire one in with
12//! [`AgentLoopBuilder::cost_estimator`](crate::AgentLoopBuilder::cost_estimator).
13//! The loop asks the estimator about every key the call could be filed under
14//! before it asks the static table about any of them, so a fresher feed price
15//! wins over a compiled-in rate even for a model both sources know.
16//!
17//! The `model-discovery` feature implements the trait for
18//! [`ModelRegistry`](agent_sdk_providers::ModelRegistry), the layered catalog,
19//! so a run can price — and budget — models whose pricing only ever appeared
20//! in a feed.
21
22use crate::llm::Usage;
23
24/// A source of provider/model pricing for run-level cost budgeting.
25///
26/// Implementations answer for a single provider/model pair at a time and
27/// report `None` when they hold no pricing of their own for it. The loop then
28/// keeps looking: at the other keys the pair may be filed under, and finally
29/// at the static capability table. So `None` must mean "I do not price this",
30/// never "this is free" — and an implementation should not answer on the
31/// static table's behalf, or the loop cannot tell the two sources apart.
32pub trait CostEstimator: Send + Sync {
33 /// Estimate the USD cost of `usage` for `provider`/`model`.
34 ///
35 /// The loop calls this once per key it believes the model could be filed
36 /// under: the provenance pair as reported, the canonical backend(s) a
37 /// transport-specific provider name serves (`openai-responses`, `vertex`,
38 /// `cloudflare-ai-gateway`, …), and — for a vendor-slug model id — the
39 /// route and vendor keys the third-party feeds use. An implementation only
40 /// needs to answer for the keys it actually holds.
41 ///
42 /// Returning `None` means "no pricing for this pair" — not "free".
43 ///
44 /// `usage` describes ONE provider call, so an implementation may read its
45 /// input-token count as that call's context size (e.g. to select a
46 /// long-context price tier). For a summed usage the loop calls
47 /// [`estimate_aggregate_cost_usd`](Self::estimate_aggregate_cost_usd)
48 /// instead.
49 fn estimate_cost_usd(&self, provider: &str, model: &str, usage: &Usage) -> Option<f64>;
50
51 /// Estimate the USD cost of a *summed* `usage` — a thread's cumulative
52 /// tokens rather than one call.
53 ///
54 /// The distinction matters to any source with context-dependent rates: ten
55 /// 50K-token calls sum to 500K input tokens without a single call ever
56 /// reaching a 272K long-context threshold, so a sum must not be read as a
57 /// context size. An implementation with no such rates can ignore the
58 /// distinction, which is what the default does.
59 fn estimate_aggregate_cost_usd(
60 &self,
61 provider: &str,
62 model: &str,
63 usage: &Usage,
64 ) -> Option<f64> {
65 self.estimate_cost_usd(provider, model, usage)
66 }
67
68 /// Estimate the USD cost *only if* `provider`/`model` resolves through a
69 /// source layer the caller must treat as authoritative over the others — a
70 /// user-registered override.
71 ///
72 /// The loop probes this across every candidate key before it takes the max
73 /// of the feed-level estimates: an override is a price the user set on
74 /// purpose (a negotiated or free rate), so it wins outright rather than
75 /// being out-maxed by a higher feed row under some derived key. The default
76 /// returns `None` — a source with no authority layers (a plain estimator)
77 /// simply has none, and all its estimates are treated as same-authority.
78 fn estimate_override_cost_usd(
79 &self,
80 _provider: &str,
81 _model: &str,
82 _usage: &Usage,
83 ) -> Option<f64> {
84 None
85 }
86
87 /// The summed-usage counterpart of
88 /// [`estimate_override_cost_usd`](Self::estimate_override_cost_usd).
89 fn estimate_override_aggregate_cost_usd(
90 &self,
91 provider: &str,
92 model: &str,
93 usage: &Usage,
94 ) -> Option<f64> {
95 self.estimate_override_cost_usd(provider, model, usage)
96 }
97
98 /// Estimate the USD cost from a source layer that has NO absolute authority
99 /// — a refreshable feed row, as opposed to a user override.
100 ///
101 /// The loop takes the max across every candidate's *feed* estimate, and
102 /// consults an override only when it is more specific than every live feed
103 /// price (see [`estimate_override_cost_usd`](Self::estimate_override_cost_usd)).
104 /// It therefore needs the feed price at a key independently of any override
105 /// on the same key. The default returns the plain estimate: a source with
106 /// no authority layers has only feed-level prices, so all of them qualify.
107 fn estimate_feed_cost_usd(&self, provider: &str, model: &str, usage: &Usage) -> Option<f64> {
108 self.estimate_cost_usd(provider, model, usage)
109 }
110
111 /// The summed-usage counterpart of
112 /// [`estimate_feed_cost_usd`](Self::estimate_feed_cost_usd).
113 fn estimate_feed_aggregate_cost_usd(
114 &self,
115 provider: &str,
116 model: &str,
117 usage: &Usage,
118 ) -> Option<f64> {
119 self.estimate_aggregate_cost_usd(provider, model, usage)
120 }
121}
122
123#[cfg(feature = "model-discovery")]
124impl CostEstimator for agent_sdk_providers::ModelRegistry {
125 fn estimate_cost_usd(&self, provider: &str, model: &str, usage: &Usage) -> Option<f64> {
126 // The dynamic layers only (override → feed). The registry's layered
127 // `estimate_cost_usd` would also answer from the static table, and a
128 // static hit on the first key tried would then pre-empt the feed's
129 // price for a key derived later — the caller applies the static table
130 // itself, after this source has been offered every key.
131 Self::estimate_dynamic_cost_usd(self, provider, model, usage)
132 }
133
134 fn estimate_aggregate_cost_usd(
135 &self,
136 provider: &str,
137 model: &str,
138 usage: &Usage,
139 ) -> Option<f64> {
140 Self::estimate_dynamic_base_cost_usd(self, provider, model, usage)
141 }
142
143 fn estimate_override_cost_usd(
144 &self,
145 provider: &str,
146 model: &str,
147 usage: &Usage,
148 ) -> Option<f64> {
149 Self::estimate_override_cost_usd(self, provider, model, usage)
150 }
151
152 fn estimate_override_aggregate_cost_usd(
153 &self,
154 provider: &str,
155 model: &str,
156 usage: &Usage,
157 ) -> Option<f64> {
158 Self::estimate_override_base_cost_usd(self, provider, model, usage)
159 }
160
161 fn estimate_feed_cost_usd(&self, provider: &str, model: &str, usage: &Usage) -> Option<f64> {
162 Self::estimate_feed_cost_usd(self, provider, model, usage)
163 }
164
165 fn estimate_feed_aggregate_cost_usd(
166 &self,
167 provider: &str,
168 model: &str,
169 usage: &Usage,
170 ) -> Option<f64> {
171 Self::estimate_feed_base_cost_usd(self, provider, model, usage)
172 }
173}
174
175#[cfg(all(test, feature = "model-discovery"))]
176mod tests {
177 use super::*;
178 use agent_sdk_providers::{CatalogEntry, ModelRegistry};
179 use anyhow::Context;
180
181 #[test]
182 fn model_registry_prices_through_the_trait() -> anyhow::Result<()> {
183 let registry = ModelRegistry::new().with_override(
184 "openai",
185 "feed-only-model",
186 CatalogEntry {
187 provider: "openai".to_owned(),
188 model_id: "feed-only-model".to_owned(),
189 context_window: None,
190 max_output_tokens: None,
191 pricing: Some(crate::model_capabilities::Pricing::flat(10.0, 20.0)),
192 pricing_tiers: Vec::new(),
193 supports_thinking: None,
194 },
195 );
196 let usage = Usage {
197 input_tokens: 1_000_000,
198 output_tokens: 1_000_000,
199 cached_input_tokens: 0,
200 cache_creation_input_tokens: 0,
201 };
202
203 let estimator: &dyn CostEstimator = ®istry;
204 let cost = estimator
205 .estimate_cost_usd("openai", "feed-only-model", &usage)
206 .context("registry must price a model it carries")?;
207 assert!((cost - 30.0).abs() < 1e-9, "unexpected cost: {cost}");
208
209 assert!(
210 estimator
211 .estimate_cost_usd("openai", "no-such-model", &usage)
212 .is_none()
213 );
214 Ok(())
215 }
216}