dwara_core/ai/
cost.rs

1//! AI cost attribution (DW-079): pricing tables and per-call cost
2//! computation.
3//!
4//! The pricing table is a COMPILED view of the `ai.pricing` config map:
5//! provider model identifier -> per-1k-token micro-USD rates. It lives
6//! on the dataplane behind an ArcSwap and is swapped on every config
7//! reload (the same pattern as the AI budget engine), so a pricing
8//! change takes effect on the NEXT request without a restart.
9//!
10//! # Cost computation
11//!
12//! `cost_micros` computes integer micro-USD for one call's usage:
13//!
14//! ```text
15//! (input_tokens  - cached_tokens) * input_per_1k_micros  / 1000
16//! + cached_tokens * cached_input_per_1k_micros           / 1000
17//! + output_tokens * output_per_1k_micros                 / 1000
18//! ```
19//!
20//! When `batch` is true, the batch rates (`batch_input_per_1k_micros`,
21//! `batch_output_per_1k_micros`) replace the standard input/output
22//! rates when declared; cached-token pricing is NOT combined with
23//! batch rates (a batch request uses the batch rate for all input
24//! tokens — the provider's batch API does not report a separate cached
25//! tier). When optional fields are absent, the standard rates apply
26//! (the fallback).
27//!
28//! Integer division truncates (the per-1k rate is the provider's
29//! published unit; sub-micro-USD fractions are lost — the honest
30//! rounding boundary, pinned by tests). Saturating adds guard against
31//! overflow. An UNKNOWN model (no pricing entry) returns `None` — the
32//! caller applies the `unknown_model_policy` (allow / fail_closed /
33//! alert, DW-AI-03). The original fail-open behavior (cost 0) is
34//! preserved by the `allow` policy at the call site.
35//!
36//! # Dependency direction
37//!
38//! `ai` depends on `config` only (see `scripts/check_deps.py`); this
39//! module reads `config::ai::AiConfig` and `ai::types::Usage`, both
40//! same-level or lower. The spend RECORD DTO lives in `analytics` (a
41//! plain struct, not importing `ai::types::Usage`) — the dataplane
42//! converts at the call site, keeping the dependency direction
43//! downward.
44
45use crate::ai::types::Usage;
46use crate::config::ai::AiConfig;
47use std::collections::BTreeMap;
48
49/// One model's per-1k-token rates (micro-USD). The optional cached and
50/// batch fields extend the standard rates (DW-AI-03); when absent the
51/// standard rates apply.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53struct Price {
54    input_per_1k_micros: u64,
55    output_per_1k_micros: u64,
56    /// Discounted rate for prompt-cached input tokens.
57    cached_input_per_1k_micros: Option<u64>,
58    /// Batch-API input rate.
59    batch_input_per_1k_micros: Option<u64>,
60    /// Batch-API output rate.
61    batch_output_per_1k_micros: Option<u64>,
62}
63
64/// The compiled pricing table (DW-079): provider model -> rates. Built
65/// at dataplane refresh from the published config; immutable once
66/// built. Stored on the dataplane behind an ArcSwap and swapped on
67/// reload, so a pricing change applies to the next request with no
68/// restart.
69#[derive(Debug, Clone, Default)]
70pub struct PricingTable {
71    prices: BTreeMap<String, Price>,
72}
73
74impl PricingTable {
75    /// Compile from the `ai:` config block's pricing map. Absent or
76    /// empty pricing yields an empty table (every model is unknown ->
77    /// the caller applies the unknown-model policy).
78    pub fn compile(cfg: Option<&AiConfig>) -> Self {
79        let prices = cfg
80            .map(|c| {
81                c.pricing
82                    .iter()
83                    .map(|(model, p)| {
84                        (
85                            model.clone(),
86                            Price {
87                                input_per_1k_micros: p.input_per_1k_micros,
88                                output_per_1k_micros: p.output_per_1k_micros,
89                                cached_input_per_1k_micros: p.cached_input_per_1k_micros,
90                                batch_input_per_1k_micros: p.batch_input_per_1k_micros,
91                                batch_output_per_1k_micros: p.batch_output_per_1k_micros,
92                            },
93                        )
94                    })
95                    .collect()
96            })
97            .unwrap_or_default();
98        PricingTable { prices }
99    }
100
101    /// Whether the table carries a pricing entry for `provider_model`.
102    /// Cheap lookup used by the dataplane to apply the unknown-model
103    /// policy (DW-AI-03) without computing the full cost.
104    pub fn has_pricing(&self, provider_model: &str) -> bool {
105        self.prices.contains_key(provider_model)
106    }
107
108    /// Micro-USD for one call's usage against this pricing table.
109    /// Returns `None` when the model has no pricing entry (the caller
110    /// applies the unknown-model policy); `Some(cost)` when it does.
111    /// Integer micro-USD, saturating. When `batch` is true, batch rates
112    /// replace the standard rates when declared. When
113    /// `usage.cached_tokens` is present and a cached rate is declared,
114    /// the cached portion of the prompt is priced at the cached rate
115    /// (not combined with batch — a batch request uses the batch rate
116    /// for all input tokens).
117    pub fn cost_micros(&self, provider_model: &str, usage: Usage, batch: bool) -> Option<u64> {
118        let price = self.prices.get(provider_model)?;
119        let input = usage.prompt_tokens.unwrap_or(0);
120        let output = usage.completion_tokens.unwrap_or(0);
121        // Batch rates replace the standard rates when declared; cached
122        // pricing is skipped under batch (the batch API does not report
123        // a separate cached tier).
124        if batch {
125            let in_rate = price
126                .batch_input_per_1k_micros
127                .unwrap_or(price.input_per_1k_micros);
128            let out_rate = price
129                .batch_output_per_1k_micros
130                .unwrap_or(price.output_per_1k_micros);
131            let input_cost = input.saturating_mul(in_rate) / 1000;
132            let output_cost = output.saturating_mul(out_rate) / 1000;
133            return Some(input_cost.saturating_add(output_cost));
134        }
135        // Standard (non-batch) path: split the prompt into cached and
136        // non-cached portions when both a cached rate and a cached
137        // token count are present.
138        let cached = usage.cached_tokens.unwrap_or(0);
139        let (input_cost, cached_cost) = if let Some(cached_rate) = price.cached_input_per_1k_micros
140        {
141            let non_cached = input.saturating_sub(cached);
142            let std_cost = non_cached.saturating_mul(price.input_per_1k_micros) / 1000;
143            let cached_cost = cached.saturating_mul(cached_rate) / 1000;
144            (std_cost, cached_cost)
145        } else {
146            let std_cost = input.saturating_mul(price.input_per_1k_micros) / 1000;
147            (std_cost, 0)
148        };
149        let output_cost = output.saturating_mul(price.output_per_1k_micros) / 1000;
150        Some(
151            input_cost
152                .saturating_add(cached_cost)
153                .saturating_add(output_cost),
154        )
155    }
156
157    /// Whether the table carries any pricing (cheap dataplane skip).
158    pub fn is_empty(&self) -> bool {
159        self.prices.is_empty()
160    }
161}