Skip to main content

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}