wcode_harness/pricing.rs
1//! Per-model prices for recognized providers, so `/usage` can report a `$` cost
2//! instead of a bare token count.
3//!
4//! Twin of [`crate::limits`], from the same source and the same gate: the
5//! authoritative catalog is models.dev, whose `opencode-go` entry
6//! (`api = https://opencode.ai/zen/go/v1`) carries
7//! `cost { input, output, cache_read, cache_write }` in **USD per million
8//! tokens**. [`OPENCODE_GO`] is a snapshot of that table, and [`model_price`] is
9//! gated by the existing [`crate::limits::is_opencode_go`] — so the two tables
10//! can never disagree about which provider they describe.
11//!
12//! Units are **micro-USD per Mtok**, integers, never floats: a price stays
13//! `Eq`/serde-stable, a cost is byte-identical across a round-trip, and the
14//! arithmetic is one integer division away from the dollars `summary()` prints.
15//!
16//! Who calls it: the actor's `Request::Usage` reply (see
17//! [`crate::protocol::Request::Usage`]) folds the transcript with
18//! [`crate::stats::session_stats`] and then prices it with [`cost_of`]; the
19//! front-ends only render the [`Cost`] it produced.
20//!
21//! NOT in v1 (named, deliberate — brief Q5):
22//! - `tiers` / `context_over_200k` — a request past the tier size bills up to
23//! ~5× the base rate, so a tiered model's number is an **undercount**;
24//! [`OPENCODE_GO`] stores the base rate only.
25//! - a per-model `base_url` from [`crate::streamfn::LlmOpts::model_profiles`] —
26//! pricing uses the session's launch base_url for every turn.
27//! - a config file. A const table mirrors `limits.rs`; behaviour is not
28//! configured (`AGENTS.md` §Conventions).
29
30use crate::message::{AgentMessage, Usage};
31use crate::stats::Cost;
32
33/// Micro-USD per Mtok: the divisor that turns `tokens × rate` into micro-USD.
34/// (`tokens / 1_000_000` Mtok × a micro-USD/Mtok rate = micro-USD.)
35const MICRO_PER_MTOK: u128 = 1_000_000;
36
37/// One model's prices: USD per million tokens, in **micro-USD** (1e-6 USD).
38///
39/// Integers so a price and a derived [`Cost`] stay `Eq` and serde-stable, and
40/// so `summary()`'s `$` is reproducible to the last digit. `Default` is the
41/// all-zero price — a *known free* model, which is `Some(ModelPrice::default())`
42/// and never `None`.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub struct ModelPrice {
45 /// Price of one million **uncached** prompt tokens.
46 pub input: u64,
47 /// Price of one million completion tokens.
48 pub output: u64,
49 /// Price of one million prompt tokens served from the provider's cache.
50 ///
51 /// A cached read is a **SUBSET of `input_tokens`** on the
52 /// chat-completions wire (rig maps `usage.prompt_tokens_details
53 /// .cached_tokens`, i.e. tokens already inside `prompt_tokens`), so a cached
54 /// token is billed at THIS rate *instead of* `input` — never on top of it.
55 pub cache_read: u64,
56 /// Price of one million tokens **written** to the cache. `0` for most
57 /// providers (models.dev omits the field when the provider does not report
58 /// it); it is not part of `input`'s accounting.
59 pub cache_write: u64,
60}
61
62impl ModelPrice {
63 /// One turn's cost numerator: `Σ tokens × rate`, i.e. micro-USD × 1e6.
64 ///
65 /// Contract:
66 /// - `cache_read_tokens` is billed at [`ModelPrice::cache_read`], the
67 /// remainder of `input_tokens` at [`ModelPrice::input`] (the subset rule).
68 /// `cache_read_tokens` is clamped to `input_tokens` so a provider that
69 /// over-reports cannot underflow the remainder.
70 /// - `cache_write_tokens` is billed at [`ModelPrice::cache_write`] on top of
71 /// that; a turn reporting no cache field pays the full `input` rate.
72 /// - the value is the un-truncated numerator so a caller folding many turns
73 /// sums numerators and divides ONCE ([`cost_of`]); one turn's answer is
74 /// [`ModelPrice::micro_usd`].
75 ///
76 /// Does NOT promise: tier rates, or that the provider reported usage at all
77 /// (a `Usage::default()` turn costs 0 — the caller decides whether a turn
78 /// counted).
79 pub fn cost_numerator(&self, usage: &Usage) -> u128 {
80 // Cached read is a SUBSET of `input_tokens`: bill it at the cache rate
81 // INSTEAD of the input rate, outside the cache rate never twice. Clamp
82 // so a provider that over-reports cannot underflow the remainder.
83 let cached = usage.cache_read_tokens.unwrap_or(0).min(usage.input_tokens);
84 let uncached = usage.input_tokens - cached;
85 let written = usage.cache_write_tokens.unwrap_or(0);
86 uncached as u128 * self.input as u128
87 + cached as u128 * self.cache_read as u128
88 + written as u128 * self.cache_write as u128
89 + usage.output_tokens as u128 * self.output as u128
90 }
91
92 /// One turn's cost in whole micro-USD, truncated.
93 ///
94 /// `micro_usd(u) == (cost_numerator(u) / 1_000_000) as u64` — a sub-micro
95 /// fraction is dropped, never rounded, so the printed `$` agrees with the
96 /// truncated micro-USD the brief's §7.2 check computes.
97 pub fn micro_usd(&self, usage: &Usage) -> u64 {
98 (self.cost_numerator(usage) / MICRO_PER_MTOK) as u64
99 }
100}
101
102/// Prices for `model` served from `base_url`, when we recognize the provider.
103///
104/// `None` = unknown provider/model — the caller OMITS the `$` (an unknown price
105/// is not `$0`; a known-free model is `Some(ModelPrice::default())`).
106///
107/// Gated by [`crate::limits::is_opencode_go`] EXACTLY like
108/// [`crate::limits::model_limit`] (`limits.rs:63` `pub fn model_limit(base_url:
109/// Option<&str>, model: &str) -> Option<ModelLimit> {`): a known id behind a
110/// non-zen base is NOT assumed — mirrors `limits::unknown_model_or_provider_is_none`.
111///
112/// Does NOT promise: id coverage. [`OPENCODE_GO`] holds the 32 ids models.dev
113/// prices; `limits::OPENCODE_GO` holds 45, so a model can have a context window
114/// and NO price (see the table doc).
115pub fn model_price(base_url: Option<&str>, model: &str) -> Option<ModelPrice> {
116 if crate::limits::is_opencode_go(base_url) {
117 return opencode_go(model);
118 }
119 None
120}
121
122/// The session's `$` cost: fold every `Assistant { usage: Some(u), model }` at
123/// the price of `model` — the message's own id when set, else
124/// `fallback_model` — under `base_url`, summing micro-USD.
125///
126/// Returns `None` when NOT ONE turn could be priced (unknown model/provider ⇒
127/// absent, never `$0`). `Cost::unpriced_turns` counts the turns that reported
128/// usage but had no price, so a mid-run model switch reads
129/// `~$0.0087 (3 turns unpriced)` instead of silently under-reporting.
130///
131/// Contract:
132/// - one numerator is accumulated across priced turns and divided ONCE, so a
133/// long session does not lose a micro-USD per turn to truncation;
134/// - a turn with `usage: None` is neither priced nor counted unpriced (it
135/// reported nothing);
136/// - `messages` is the same slice [`crate::stats::session_stats`] folded, so
137/// `stats.turns == priced + unpriced` whenever `cost` is `Some`.
138///
139/// Does NOT promise: tier rates; a per-model `base_url` (a
140/// [`crate::streamfn::ModelProfiles`] override is ignored — the launch base is
141/// used for every turn); compaction does not rewrite the messages priced here.
142pub fn cost_of(
143 messages: &[AgentMessage],
144 base_url: Option<&str>,
145 fallback_model: &str,
146) -> Option<Cost> {
147 let mut numerator: u128 = 0;
148 let mut unpriced_turns: u64 = 0;
149 let mut priced = false;
150 for m in messages {
151 let AgentMessage::Assistant { usage, model, .. } = m else {
152 continue;
153 };
154 // A turn that reported nothing is neither priced nor counted unpriced.
155 let Some(u) = usage else {
156 continue;
157 };
158 let id = model.as_deref().unwrap_or(fallback_model);
159 match model_price(base_url, id) {
160 Some(price) => {
161 numerator += price.cost_numerator(u);
162 priced = true;
163 }
164 None => unpriced_turns += 1,
165 }
166 }
167 // Not one turn could be priced ⇒ absence, never `$0`.
168 if !priced {
169 return None;
170 }
171 // Divide ONCE so a long session does not lose a micro-USD per turn.
172 Some(Cost {
173 micro_usd: (numerator / MICRO_PER_MTOK) as u64,
174 unpriced_turns,
175 })
176}
177
178/// Table lookup, mirroring `limits::opencode_go` (`limits.rs:93`
179/// `fn opencode_go(model: &str) -> Option<ModelLimit> {`): first row whose id
180/// matches exactly. No prefix/normalization — an id is an id.
181fn opencode_go(model: &str) -> Option<ModelPrice> {
182 OPENCODE_GO
183 .iter()
184 .find(|(id, ..)| *id == model)
185 .map(|(_, input, output, cache_read, cache_write)| ModelPrice {
186 input: *input,
187 output: *output,
188 cache_read: *cache_read,
189 cache_write: *cache_write,
190 })
191}
192
193/// OpenCode Go (zen) price table, snapshotted from the models.dev `opencode-go`
194/// catalog (`https://models.dev/api.json` → `opencode-go` → `models.<id>.cost`,
195/// 32 models): `(model id, input, output, cache_read, cache_write)` in
196/// **micro-USD per Mtok** (USD/Mtok × 1_000_000 — `0.15` ⇒ `150_000`,
197/// `0.003` ⇒ `3_000`).
198///
199/// Source notes, per the `limits.rs` discipline of citing where the numbers come
200/// from:
201/// - every value converts EXACTLY to micro-USD for this snapshot (checked: no
202/// entry carries more than 6 decimals), so no rounding happens on the way in;
203/// - `cache_write` is `0` where models.dev omits it (the provider does not
204/// report a separate write price);
205/// - four ids are priced at all-zero = genuinely free: `beam-free`,
206/// `cat-jam-free`, `longcat-2.5-preview-free`, `step-5-preview-free`;
207/// - `tiers` / `context_over_200k` are DROPPED: the base rate is stored, so a
208/// tiered model (`grok-4.7`, `gpt-5.6-luna`, `gpt-6-luna`, `minimax-m3`,
209/// `claude-haiku-5-5`, `qwen3.7-plus`) is an undercount above its threshold.
210///
211/// Coverage gap (named, not a bug to hide): 17 ids in `limits::OPENCODE_GO` have
212/// no row here — `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `glm-5`,
213/// `glm-5.1`, `grok-4.5`, `grok-4.6`, `kimi-k2.5`, `kimi-k2.6`, `mimo-v2-omni`,
214/// `mimo-v2-pro`, `minimax-m2.5`, `omen-alpha`, `ox-alpha-free`,
215/// `qwen3.5-plus`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny-free`. They get
216/// a context window but NO `$`. Conversely `beam-free`, `cat-jam-free`,
217/// `gemini-4-argon-contributor`, `step-5-preview` are priced here and absent
218/// from the limits table — harmless: the two tables answer different questions.
219static OPENCODE_GO: &[(&str, u64, u64, u64, u64)] = &[
220 ("beam-free", 0, 0, 0, 0),
221 ("cat-jam-free", 0, 0, 0, 0),
222 ("claude-haiku-5-5", 100_000, 500_000, 10_000, 125_000),
223 ("deepseek-v4-pro", 660_000, 1_980_000, 22_000, 0),
224 ("deepseek-v4.1-flash", 150_000, 600_000, 3_000, 0),
225 ("gemini-4-argon-contributor", 4_000_000, 20_000_000, 200_000, 0),
226 ("glm-5.2", 1_400_000, 4_400_000, 260_000, 0),
227 ("glm-5.3", 1_400_000, 4_400_000, 260_000, 0),
228 ("glm-5.3-flash", 150_000, 500_000, 30_000, 0),
229 ("gpt-5.6-luna", 200_000, 1_200_000, 20_000, 250_000),
230 ("gpt-6-luna", 100_000, 500_000, 10_000, 125_000),
231 ("grok-4.7", 2_000_000, 6_000_000, 500_000, 0),
232 ("hy3", 140_000, 580_000, 35_000, 0),
233 ("hy4-preview", 834_000, 2_501_000, 42_000, 0),
234 ("kimi-k2.7-code", 950_000, 4_000_000, 190_000, 0),
235 ("kimi-k3", 3_000_000, 15_000_000, 300_000, 0),
236 ("longcat-2.0", 300_000, 1_200_000, 6_000, 0),
237 ("longcat-2.5-preview-free", 0, 0, 0, 0),
238 ("mimo-v2.5", 140_000, 280_000, 2_800, 0),
239 ("mimo-v2.5-pro", 435_000, 870_000, 3_625, 0),
240 ("mimo-v2.6-flash", 140_000, 280_000, 2_800, 0),
241 ("mimo-v2.6-pro", 435_000, 870_000, 3_625, 0),
242 ("minimax-m2.7", 300_000, 1_200_000, 60_000, 375_000),
243 ("minimax-m3", 300_000, 1_200_000, 60_000, 0),
244 ("muse-spark-1.2-contributor", 100_000, 200_000, 2_000, 0),
245 ("muse-spark-1.3-contributor", 100_000, 200_000, 2_000, 0),
246 ("qwen3.7-plus", 400_000, 1_600_000, 40_000, 500_000),
247 ("qwen3.8-flash", 150_000, 470_000, 16_000, 200_000),
248 ("qwen3.8-max", 2_000_000, 6_000_000, 250_000, 2_500_000),
249 ("space-bunny", 150_000, 600_000, 30_000, 0),
250 ("step-5-preview", 1_000_000, 2_700_000, 100_000, 0),
251 ("step-5-preview-free", 0, 0, 0, 0),
252];
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::message::{ContentBlock, StopReason};
258
259 const ZEN: Option<&str> = Some("https://opencode.ai/zen/go/v1");
260
261 fn usage(input: u64, output: u64, read: Option<u64>, write: Option<u64>) -> Usage {
262 Usage {
263 input_tokens: input,
264 output_tokens: output,
265 cache_read_tokens: read,
266 cache_write_tokens: write,
267 }
268 }
269
270 fn assistant(u: Option<Usage>, model: Option<&str>) -> AgentMessage {
271 AgentMessage::Assistant {
272 content: vec![ContentBlock::Text { text: "a".into() }],
273 stop_reason: StopReason::Stop,
274 usage: u,
275 model: model.map(|m| m.to_string()),
276 }
277 }
278
279 /// models.dev `opencode-go` → `deepseek-v4.1-flash`:
280 /// `{"input":0.15,"output":0.6,"cache_read":0.003}` (no `cache_write`) ⇒
281 /// `150_000 / 600_000 / 3_000 / 0` micro-USD per Mtok.
282 #[test]
283 fn deepseek_v4_1_flash_prices_match_models_dev() {
284 assert_eq!(
285 model_price(ZEN, "deepseek-v4.1-flash"),
286 Some(ModelPrice {
287 input: 150_000,
288 output: 600_000,
289 cache_read: 3_000,
290 cache_write: 0,
291 })
292 );
293 }
294
295 /// A genuinely free model is `Some`, not `None`: `Some(ModelPrice::default())`
296 /// ⇒ `~$0.0000` on screen, while `None` prints nothing. Pick a priced-zero id
297 /// (`step-5-preview-free`); `ox-alpha-free` looks free but has NO row, so it
298 /// is the None case in the test below.
299 #[test]
300 fn a_free_model_is_some_zero_not_none() {
301 assert_eq!(
302 model_price(ZEN, "step-5-preview-free"),
303 Some(ModelPrice::default())
304 );
305 }
306
307 /// The `limits::unknown_model_or_provider_is_none` matrix, for prices:
308 /// an unknown id under a zen base, a known id behind a non-zen base, and
309 /// `None` as the base all collapse to `None` (never `$0`).
310 #[test]
311 fn unknown_model_or_provider_is_none() {
312 // Unknown id behind a zen base.
313 assert_eq!(model_price(ZEN, "no-such-model"), None);
314 // A known id behind a non-zen base is NOT assumed.
315 assert_eq!(
316 model_price(Some("https://api.openai.com/v1"), "deepseek-v4.1-flash"),
317 None
318 );
319 // No base at all.
320 assert_eq!(model_price(None, "deepseek-v4.1-flash"), None);
321 // `ox-alpha-free` looks free but has NO row in the pricing snapshot.
322 assert_eq!(model_price(ZEN, "ox-alpha-free"), None);
323 }
324
325 /// The subset rule, in exact micro-USD. For `deepseek-v4.1-flash` with
326 /// `input = 1_000`, `cache_read = 400`, `output = 500`:
327 /// `(1_000−400)·150_000 + 400·3_000 + 500·600_000 = 391_200_000`, ÷1e6 ⇒
328 /// **391 µUSD**. Also: no cache field ⇒ the full input rate
329 /// (`(1_000·150_000)/1e6 = 150`), i.e. cached tokens are never charged twice.
330 #[test]
331 fn cost_charges_the_cached_subset_at_the_cache_rate() {
332 let p = model_price(ZEN, "deepseek-v4.1-flash").expect("a priced model");
333 let u = usage(1_000, 500, Some(400), None);
334 // (1000-400)*150_000 + 400*3_000 + 500*600_000 = 391_200_000, /1e6 = 391.
335 assert_eq!(p.cost_numerator(&u), 391_200_000);
336 assert_eq!(p.micro_usd(&u), 391);
337 // No cache field ⇒ the FULL input rate: (1000*150_000)/1e6 = 150.
338 // (If cache_read were charged at the input rate WITHOUT subtracting it
339 // from the uncached remainder, this would read 190 — the failure the
340 // test guards against.)
341 assert_eq!(p.micro_usd(&usage(1_000, 0, None, None)), 150);
342 // A cached read is not charged twice: the same turn with cache_read ==
343 // input costs the cache rate for all of it.
344 assert_eq!(p.micro_usd(&usage(1_000, 0, Some(1_000), None)), 3);
345 }
346
347 /// ADDITIVE (sketch recommendation — the brief's §7.1 list stops at four, but
348 /// `cost_of` is the fn the actor calls and would otherwise be untested):
349 /// two priced turns + one unpriced model ⇒ `Some(Cost { micro_usd, Some(1) })`;
350 /// a session with no priced turn ⇒ `None`.
351 #[test]
352 fn cost_of_counts_the_turns_it_could_not_price() {
353 // Two priced turns (deepseek-v4.1-flash) + one unpriced model id.
354 let messages = vec![
355 // 1_000_000 * 150_000 / 1e6 = 150_000 µUSD.
356 assistant(Some(usage(1_000_000, 0, None, None)), Some("deepseek-v4.1-flash")),
357 // No per-message model ⇒ the fallback id is used → 150_000 µUSD.
358 assistant(Some(usage(1_000_000, 0, None, None)), None),
359 // An unknown id: reported usage, no price ⇒ one unpriced turn.
360 assistant(Some(usage(1_000, 1_000, None, None)), Some("no-such-model")),
361 ];
362 let cost = cost_of(&messages, ZEN, "deepseek-v4.1-flash").expect("some priced turn");
363 assert_eq!(cost.micro_usd, 300_000);
364 assert_eq!(cost.unpriced_turns, 1);
365
366 // Not one priced turn ⇒ None, never $0.
367 let unpriced = vec![assistant(Some(usage(10, 10, None, None)), Some("no-such-model"))];
368 assert_eq!(cost_of(&unpriced, ZEN, "no-such-model"), None);
369 // A session that reported no usage is None too.
370 assert_eq!(cost_of(&[], ZEN, "deepseek-v4.1-flash"), None);
371 }
372}