Skip to main content

kcode_intelligence_router/
pricing.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{Metering, TokenUsage};
4
5/// Published provider prices compiled into this Kennedy build.
6pub const PRICING_VERSION: &str = "kennedy-provider-pricing-2026-07-30";
7
8/// Confidence in a locally calculated provider cost.
9#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CostAccuracy {
12    /// Provider usage maps completely to published rates.
13    Exact,
14    /// Provider usage omitted a billing detail and Kennedy used a documented fallback.
15    Estimated,
16    /// The estimate deliberately assumes a potentially-free usage item is chargeable.
17    Conservative,
18}
19
20/// A non-negative estimated cost represented exactly in billionths of one US dollar.
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct CostEstimate {
24    pub usd_nanos: u64,
25    pub accuracy: CostAccuracy,
26    pub pricing_version: String,
27}
28
29impl CostEstimate {
30    pub(crate) fn versioned(mut self) -> Self {
31        self.pricing_version = PRICING_VERSION.into();
32        self
33    }
34
35    pub(crate) fn with_surcharge(mut self, usd_nanos: u64, accuracy: CostAccuracy) -> Self {
36        self.usd_nanos = self.usd_nanos.saturating_add(usd_nanos);
37        if accuracy == CostAccuracy::Conservative
38            || accuracy == CostAccuracy::Estimated && self.accuracy == CostAccuracy::Exact
39        {
40            self.accuracy = accuracy;
41        }
42        self
43    }
44}
45
46#[derive(Clone, Copy)]
47struct TokenRates {
48    input: u64,
49    cached_input: u64,
50    output: u64,
51}
52
53/// Estimates one normalized call using Kennedy's compiled standard-tier price catalog.
54///
55/// Thinking tokens are billed at the model's output rate. Unknown models and
56/// unavailable provider metering intentionally return `None`.
57pub fn estimate_cost(model: &str, metering: &Metering) -> Option<CostEstimate> {
58    match metering {
59        Metering::Tokens(usage) => estimate_token_cost(model, *usage),
60        Metering::DurationSeconds { seconds }
61            if model == kcode_openai_api::GPT_4O_TRANSCRIBE
62                && seconds.is_finite()
63                && *seconds >= 0.0 =>
64        {
65            // OpenAI publishes an estimated $0.006/minute, or $0.0001/second.
66            Some(
67                CostEstimate {
68                    usd_nanos: (*seconds * 100_000.0).round() as u64,
69                    accuracy: CostAccuracy::Estimated,
70                    pricing_version: String::new(),
71                }
72                .versioned(),
73            )
74        }
75        _ => None,
76    }
77}
78
79/// Estimates normalized token usage for a known model.
80pub fn estimate_token_cost(model: &str, usage: TokenUsage) -> Option<CostEstimate> {
81    let total_input = usage.input_tokens.saturating_add(usage.cached_input_tokens);
82    let rates = match model {
83        // `gpt-5.6` is the direct-API alias for Sol.
84        "gpt-5.6" | "gpt-5.6-sol" if total_input <= 272_000 => TokenRates {
85            input: 5_000,
86            cached_input: 500,
87            output: 30_000,
88        },
89        "gpt-5.6" | "gpt-5.6-sol" => TokenRates {
90            input: 10_000,
91            cached_input: 1_000,
92            output: 45_000,
93        },
94        "gpt-5.6-terra" if total_input <= 272_000 => TokenRates {
95            input: 2_000,
96            cached_input: 200,
97            output: 12_000,
98        },
99        "gpt-5.6-terra" => TokenRates {
100            input: 4_000,
101            cached_input: 400,
102            output: 18_000,
103        },
104        "gpt-5.6-luna" if total_input <= 272_000 => TokenRates {
105            input: 200,
106            cached_input: 20,
107            output: 1_200,
108        },
109        "gpt-5.6-luna" => TokenRates {
110            input: 400,
111            cached_input: 40,
112            output: 1_800,
113        },
114        "gpt-4o-transcribe" => TokenRates {
115            input: 2_500,
116            cached_input: 2_500,
117            output: 10_000,
118        },
119        "gemini-2.5-flash" => TokenRates {
120            input: 300,
121            cached_input: 30,
122            output: 2_500,
123        },
124        "gemini-3.1-flash-lite" => TokenRates {
125            input: 250,
126            cached_input: 25,
127            output: 1_500,
128        },
129        "gemini-3.1-pro-preview" if total_input <= 200_000 => TokenRates {
130            input: 2_000,
131            cached_input: 200,
132            output: 12_000,
133        },
134        "gemini-3.1-pro-preview" => TokenRates {
135            input: 4_000,
136            cached_input: 400,
137            output: 18_000,
138        },
139        // Without modality detail, assume native image output for this image model.
140        "gemini-3-pro-image" => TokenRates {
141            input: 2_000,
142            cached_input: 2_000,
143            output: 120_000,
144        },
145        "gpt-image-2" => TokenRates {
146            input: 8_000,
147            cached_input: 2_000,
148            output: 30_000,
149        },
150        _ => return None,
151    };
152    Some(
153        CostEstimate {
154            usd_nanos: usage
155                .input_tokens
156                .saturating_mul(rates.input)
157                .saturating_add(usage.cached_input_tokens.saturating_mul(rates.cached_input))
158                .saturating_add(
159                    usage
160                        .thinking_tokens
161                        .saturating_add(usage.output_tokens)
162                        .saturating_mul(rates.output),
163                ),
164            accuracy: if matches!(model, "gpt-image-2" | "gemini-3-pro-image") {
165                CostAccuracy::Conservative
166            } else {
167                CostAccuracy::Exact
168            },
169            pricing_version: String::new(),
170        }
171        .versioned(),
172    )
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn openai_text_uses_distinct_cached_and_output_rates() {
181        let cost = estimate_token_cost(
182            "gpt-5.6-sol",
183            TokenUsage {
184                input_tokens: 1_000_000,
185                cached_input_tokens: 1_000_000,
186                thinking_tokens: 500_000,
187                output_tokens: 500_000,
188            },
189        )
190        .unwrap();
191        // This crosses the long-context threshold: $10 + $1 + $45.
192        assert_eq!(cost.usd_nanos, 56_000_000_000);
193    }
194
195    #[test]
196    fn thinking_is_billed_at_output_rate() {
197        let cost = estimate_token_cost(
198            "gemini-3.1-flash-lite",
199            TokenUsage {
200                thinking_tokens: 10,
201                output_tokens: 5,
202                ..TokenUsage::default()
203            },
204        )
205        .unwrap();
206        assert_eq!(cost.usd_nanos, 22_500);
207    }
208
209    #[test]
210    fn unknown_models_are_not_silently_free() {
211        assert_eq!(
212            estimate_token_cost("future-model", TokenUsage::default()),
213            None
214        );
215    }
216}