use crate::message::{AgentMessage, Usage};
use crate::stats::Cost;
const MICRO_PER_MTOK: u128 = 1_000_000;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ModelPrice {
pub input: u64,
pub output: u64,
pub cache_read: u64,
pub cache_write: u64,
}
impl ModelPrice {
pub fn cost_numerator(&self, usage: &Usage) -> u128 {
let cached = usage.cache_read_tokens.unwrap_or(0).min(usage.input_tokens);
let uncached = usage.input_tokens - cached;
let written = usage.cache_write_tokens.unwrap_or(0);
uncached as u128 * self.input as u128
+ cached as u128 * self.cache_read as u128
+ written as u128 * self.cache_write as u128
+ usage.output_tokens as u128 * self.output as u128
}
pub fn micro_usd(&self, usage: &Usage) -> u64 {
(self.cost_numerator(usage) / MICRO_PER_MTOK) as u64
}
}
pub fn model_price(base_url: Option<&str>, model: &str) -> Option<ModelPrice> {
if crate::limits::is_opencode_go(base_url) {
return opencode_go(model);
}
None
}
pub fn cost_of(
messages: &[AgentMessage],
base_url: Option<&str>,
fallback_model: &str,
) -> Option<Cost> {
let mut numerator: u128 = 0;
let mut unpriced_turns: u64 = 0;
let mut priced = false;
for m in messages {
let AgentMessage::Assistant { usage, model, .. } = m else {
continue;
};
let Some(u) = usage else {
continue;
};
let id = model.as_deref().unwrap_or(fallback_model);
match model_price(base_url, id) {
Some(price) => {
numerator += price.cost_numerator(u);
priced = true;
}
None => unpriced_turns += 1,
}
}
if !priced {
return None;
}
Some(Cost {
micro_usd: (numerator / MICRO_PER_MTOK) as u64,
unpriced_turns,
})
}
fn opencode_go(model: &str) -> Option<ModelPrice> {
OPENCODE_GO
.iter()
.find(|(id, ..)| *id == model)
.map(|(_, input, output, cache_read, cache_write)| ModelPrice {
input: *input,
output: *output,
cache_read: *cache_read,
cache_write: *cache_write,
})
}
static OPENCODE_GO: &[(&str, u64, u64, u64, u64)] = &[
("beam-free", 0, 0, 0, 0),
("cat-jam-free", 0, 0, 0, 0),
("claude-haiku-5-5", 100_000, 500_000, 10_000, 125_000),
("deepseek-v4-pro", 660_000, 1_980_000, 22_000, 0),
("deepseek-v4.1-flash", 150_000, 600_000, 3_000, 0),
("gemini-4-argon-contributor", 4_000_000, 20_000_000, 200_000, 0),
("glm-5.2", 1_400_000, 4_400_000, 260_000, 0),
("glm-5.3", 1_400_000, 4_400_000, 260_000, 0),
("glm-5.3-flash", 150_000, 500_000, 30_000, 0),
("gpt-5.6-luna", 200_000, 1_200_000, 20_000, 250_000),
("gpt-6-luna", 100_000, 500_000, 10_000, 125_000),
("grok-4.7", 2_000_000, 6_000_000, 500_000, 0),
("hy3", 140_000, 580_000, 35_000, 0),
("hy4-preview", 834_000, 2_501_000, 42_000, 0),
("kimi-k2.7-code", 950_000, 4_000_000, 190_000, 0),
("kimi-k3", 3_000_000, 15_000_000, 300_000, 0),
("longcat-2.0", 300_000, 1_200_000, 6_000, 0),
("longcat-2.5-preview-free", 0, 0, 0, 0),
("mimo-v2.5", 140_000, 280_000, 2_800, 0),
("mimo-v2.5-pro", 435_000, 870_000, 3_625, 0),
("mimo-v2.6-flash", 140_000, 280_000, 2_800, 0),
("mimo-v2.6-pro", 435_000, 870_000, 3_625, 0),
("minimax-m2.7", 300_000, 1_200_000, 60_000, 375_000),
("minimax-m3", 300_000, 1_200_000, 60_000, 0),
("muse-spark-1.2-contributor", 100_000, 200_000, 2_000, 0),
("muse-spark-1.3-contributor", 100_000, 200_000, 2_000, 0),
("qwen3.7-plus", 400_000, 1_600_000, 40_000, 500_000),
("qwen3.8-flash", 150_000, 470_000, 16_000, 200_000),
("qwen3.8-max", 2_000_000, 6_000_000, 250_000, 2_500_000),
("space-bunny", 150_000, 600_000, 30_000, 0),
("step-5-preview", 1_000_000, 2_700_000, 100_000, 0),
("step-5-preview-free", 0, 0, 0, 0),
];
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{ContentBlock, StopReason};
const ZEN: Option<&str> = Some("https://opencode.ai/zen/go/v1");
fn usage(input: u64, output: u64, read: Option<u64>, write: Option<u64>) -> Usage {
Usage {
input_tokens: input,
output_tokens: output,
cache_read_tokens: read,
cache_write_tokens: write,
}
}
fn assistant(u: Option<Usage>, model: Option<&str>) -> AgentMessage {
AgentMessage::Assistant {
content: vec![ContentBlock::Text { text: "a".into() }],
stop_reason: StopReason::Stop,
usage: u,
model: model.map(|m| m.to_string()),
}
}
#[test]
fn deepseek_v4_1_flash_prices_match_models_dev() {
assert_eq!(
model_price(ZEN, "deepseek-v4.1-flash"),
Some(ModelPrice {
input: 150_000,
output: 600_000,
cache_read: 3_000,
cache_write: 0,
})
);
}
#[test]
fn a_free_model_is_some_zero_not_none() {
assert_eq!(
model_price(ZEN, "step-5-preview-free"),
Some(ModelPrice::default())
);
}
#[test]
fn unknown_model_or_provider_is_none() {
assert_eq!(model_price(ZEN, "no-such-model"), None);
assert_eq!(
model_price(Some("https://api.openai.com/v1"), "deepseek-v4.1-flash"),
None
);
assert_eq!(model_price(None, "deepseek-v4.1-flash"), None);
assert_eq!(model_price(ZEN, "ox-alpha-free"), None);
}
#[test]
fn cost_charges_the_cached_subset_at_the_cache_rate() {
let p = model_price(ZEN, "deepseek-v4.1-flash").expect("a priced model");
let u = usage(1_000, 500, Some(400), None);
assert_eq!(p.cost_numerator(&u), 391_200_000);
assert_eq!(p.micro_usd(&u), 391);
assert_eq!(p.micro_usd(&usage(1_000, 0, None, None)), 150);
assert_eq!(p.micro_usd(&usage(1_000, 0, Some(1_000), None)), 3);
}
#[test]
fn cost_of_counts_the_turns_it_could_not_price() {
let messages = vec![
assistant(Some(usage(1_000_000, 0, None, None)), Some("deepseek-v4.1-flash")),
assistant(Some(usage(1_000_000, 0, None, None)), None),
assistant(Some(usage(1_000, 1_000, None, None)), Some("no-such-model")),
];
let cost = cost_of(&messages, ZEN, "deepseek-v4.1-flash").expect("some priced turn");
assert_eq!(cost.micro_usd, 300_000);
assert_eq!(cost.unpriced_turns, 1);
let unpriced = vec![assistant(Some(usage(10, 10, None, None)), Some("no-such-model"))];
assert_eq!(cost_of(&unpriced, ZEN, "no-such-model"), None);
assert_eq!(cost_of(&[], ZEN, "deepseek-v4.1-flash"), None);
}
}