use axum::Json;
use axum::extract::State;
use hashbrown::HashSet;
use serde::Serialize;
use crate::config::{ModelConfig, PricingDefault};
use crate::state::AppState;
#[derive(Serialize)]
pub(super) struct PricingBody {
models: Vec<PricingModel>,
}
#[derive(Serialize)]
struct PricingModel {
id: String,
owned_by: String,
#[serde(skip_serializing_if = "Option::is_none")]
scheme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
price: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
request_floor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
input_per_million: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
output_per_million: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cached_input_per_million: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_per_million: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ceiling_multiplier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
max_ceiling: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
max_input_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
default_max_output_tokens: Option<u32>,
}
pub(super) async fn list(State(state): State<AppState>) -> Json<PricingBody> {
let default = state.pricing().and_then(|pricing| pricing.default.as_ref());
let mut models: Vec<PricingModel> = state
.models()
.iter()
.map(|model| pricing_model(model, default))
.collect();
if default.is_some() {
let seen: HashSet<String> = models.iter().map(|model| model.id.clone()).collect();
for (id, owned_by) in state.upstream_pricing_rows().await {
if seen.contains(&id) {
continue;
}
models.push(default_pricing_model(&id, &owned_by, default));
}
}
Json(PricingBody { models })
}
fn pricing_model(model: &ModelConfig, default: Option<&PricingDefault>) -> PricingModel {
PricingModel {
id: model.id.clone(),
owned_by: model.owned_by.as_deref().unwrap_or("system").to_owned(),
scheme: inherit_str(model.scheme.as_ref(), default, |row| row.scheme.as_ref()),
price: inherit_str(model.price.as_ref(), default, |row| row.price.as_ref()),
request_floor: inherit_str(model.request_floor.as_ref(), default, |row| {
row.request_floor.as_ref()
}),
input_per_million: inherit_str(model.input_per_million.as_ref(), default, |row| {
row.input_per_million.as_ref()
}),
output_per_million: inherit_str(model.output_per_million.as_ref(), default, |row| {
row.output_per_million.as_ref()
}),
cached_input_per_million: inherit_str(
model.cached_input_per_million.as_ref(),
default,
|row| row.cached_input_per_million.as_ref(),
),
reasoning_per_million: inherit_str(model.reasoning_per_million.as_ref(), default, |row| {
row.reasoning_per_million.as_ref()
}),
ceiling_multiplier: inherit_str(model.ceiling_multiplier.as_ref(), default, |row| {
row.ceiling_multiplier.as_ref()
}),
max_ceiling: inherit_str(model.max_ceiling.as_ref(), default, |row| {
row.max_ceiling.as_ref()
}),
max_input_tokens: inherit_u32(model.max_input_tokens, default, |row| row.max_input_tokens),
default_max_output_tokens: inherit_u32(model.default_max_output_tokens, default, |row| {
row.default_max_output_tokens
}),
}
}
fn inherit_str(
model: Option<&String>,
default: Option<&PricingDefault>,
pick: impl Fn(&PricingDefault) -> Option<&String>,
) -> Option<String> {
model.or_else(|| default.and_then(pick)).cloned()
}
fn inherit_u32(
model: Option<u32>,
default: Option<&PricingDefault>,
pick: impl Fn(&PricingDefault) -> Option<u32>,
) -> Option<u32> {
model.or_else(|| default.and_then(pick))
}
fn default_pricing_model(
id: &str,
owned_by: &str,
default: Option<&PricingDefault>,
) -> PricingModel {
PricingModel {
id: id.to_owned(),
owned_by: owned_by.to_owned(),
scheme: inherit_str(None, default, |row| row.scheme.as_ref()),
price: inherit_str(None, default, |row| row.price.as_ref()),
request_floor: inherit_str(None, default, |row| row.request_floor.as_ref()),
input_per_million: inherit_str(None, default, |row| row.input_per_million.as_ref()),
output_per_million: inherit_str(None, default, |row| row.output_per_million.as_ref()),
cached_input_per_million: inherit_str(None, default, |row| {
row.cached_input_per_million.as_ref()
}),
reasoning_per_million: inherit_str(None, default, |row| row.reasoning_per_million.as_ref()),
ceiling_multiplier: inherit_str(None, default, |row| row.ceiling_multiplier.as_ref()),
max_ceiling: inherit_str(None, default, |row| row.max_ceiling.as_ref()),
max_input_tokens: inherit_u32(None, default, |row| row.max_input_tokens),
default_max_output_tokens: inherit_u32(None, default, |row| row.default_max_output_tokens),
}
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use crate::config::Config;
use crate::http::app;
#[tokio::test]
async fn inherits_pricing_default() {
let toml = r#"
[payment]
enabled = false
[pricing.default]
scheme = "exact"
price = "0.001"
[[upstreams]]
name = "stub"
base_url = "http://127.0.0.1:9"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "stub"
owned_by = "openai"
"#;
let response = app(Config::from_toml_str(toml).expect("config"))
.expect("router")
.oneshot(
Request::get("/v1/pricing")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK, "status");
let bytes = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("body");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
let models = body
.get("models")
.and_then(serde_json::Value::as_array)
.expect("models");
assert_eq!(models.len(), 1, "len");
let model = models.first().expect("model");
assert_eq!(
model.get("id").and_then(serde_json::Value::as_str),
Some("gpt-4o-mini"),
"id"
);
assert_eq!(
model.get("scheme").and_then(serde_json::Value::as_str),
Some("exact"),
"scheme"
);
assert_eq!(
model.get("price").and_then(serde_json::Value::as_str),
Some("0.001"),
"price"
);
}
#[tokio::test]
async fn shows_upto_rates() {
let toml = r#"
[payment]
enabled = false
[pricing.default]
scheme = "upto"
request_floor = "0.00001"
input_per_million = "0.15"
output_per_million = "0.60"
cached_input_per_million = "0.075"
reasoning_per_million = "0.60"
max_ceiling = "5.00"
max_input_tokens = 128000
default_max_output_tokens = 16384
[[upstreams]]
name = "stub"
base_url = "http://127.0.0.1:9"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "stub"
owned_by = "openai"
"#;
let response = app(Config::from_toml_str(toml).expect("config"))
.expect("router")
.oneshot(
Request::get("/v1/pricing")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK, "status");
let bytes = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("body");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
let model = body
.get("models")
.and_then(serde_json::Value::as_array)
.and_then(|models| models.first())
.expect("model");
assert_eq!(
model.get("scheme").and_then(serde_json::Value::as_str),
Some("upto"),
"scheme"
);
assert_eq!(
model
.get("input_per_million")
.and_then(serde_json::Value::as_str),
Some("0.15"),
"input"
);
assert_eq!(
model
.get("output_per_million")
.and_then(serde_json::Value::as_str),
Some("0.60"),
"output"
);
assert_eq!(
model
.get("cached_input_per_million")
.and_then(serde_json::Value::as_str),
Some("0.075"),
"cached"
);
}
#[tokio::test]
async fn embeddings_explicit_zero_output_rate() {
let toml = r#"
[payment]
enabled = false
[pricing.default]
scheme = "upto"
input_per_million = "0.15"
output_per_million = "0.60"
reasoning_per_million = "0.60"
default_max_output_tokens = 16384
[[upstreams]]
name = "stub"
base_url = "http://127.0.0.1:9"
api_key = "sk-test"
[[models]]
id = "text-embedding-3-small"
upstream = "stub"
scheme = "upto"
input_per_million = "0.02"
output_per_million = "0"
max_input_tokens = 8191
"#;
let response = app(Config::from_toml_str(toml).expect("config"))
.expect("router")
.oneshot(
Request::get("/v1/pricing")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK, "status");
let bytes = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("body");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
let model = body
.get("models")
.and_then(serde_json::Value::as_array)
.and_then(|models| models.first())
.expect("model");
assert_eq!(
model
.get("input_per_million")
.and_then(serde_json::Value::as_str),
Some("0.02"),
"input"
);
assert_eq!(
model
.get("output_per_million")
.and_then(serde_json::Value::as_str),
Some("0"),
"explicit zero output"
);
}
#[tokio::test]
async fn empty_when_no_models() {
let toml = r#"
[payment]
enabled = false
[[upstreams]]
name = "stub"
base_url = "http://127.0.0.1:9"
api_key = "sk-test"
"#;
let response = app(Config::from_toml_str(toml).expect("config"))
.expect("router")
.oneshot(
Request::get("/v1/pricing")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK, "status");
let bytes = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("body");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("models")
.and_then(serde_json::Value::as_array)
.map(Vec::len),
Some(0),
"empty"
);
}
#[tokio::test]
async fn includes_upstream_models_at_default_rates() {
let upstream = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v1/models"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [
{"id": "gpt-4o-mini", "owned_by": "openai"},
{"id": "openrouter/deepseek/deepseek-chat", "owned_by": "deepseek"}
]
})),
)
.mount(&upstream)
.await;
let toml = format!(
r#"
[payment]
enabled = false
[pricing.default]
scheme = "upto"
price = "0.001"
request_floor = "0.00001"
input_per_million = "0.15"
output_per_million = "0.60"
max_ceiling = "5.00"
max_input_tokens = 128000
default_max_output_tokens = 256
[[upstreams]]
name = "stub"
base_url = "{}"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "stub"
owned_by = "openai"
input_per_million = "0.02"
"#,
upstream.uri()
);
let response = app(Config::from_toml_str(&toml).expect("config"))
.expect("router")
.oneshot(
Request::get("/v1/pricing")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK, "status");
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
.await
.expect("body");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
let models = body
.get("models")
.and_then(serde_json::Value::as_array)
.expect("models");
assert_eq!(models.len(), 2, "catalog + upstream");
let catalog = models.first().expect("catalog");
let extra = models.get(1).expect("upstream row");
assert_eq!(
catalog.get("id").and_then(serde_json::Value::as_str),
Some("gpt-4o-mini"),
"catalog first"
);
assert_eq!(
catalog
.get("input_per_million")
.and_then(serde_json::Value::as_str),
Some("0.02"),
"catalog override"
);
assert_eq!(
extra.get("id").and_then(serde_json::Value::as_str),
Some("openrouter/deepseek/deepseek-chat"),
"upstream id"
);
assert_eq!(
extra
.get("input_per_million")
.and_then(serde_json::Value::as_str),
Some("0.15"),
"default rate"
);
assert_eq!(
extra.get("scheme").and_then(serde_json::Value::as_str),
Some("upto"),
"default scheme"
);
}
}