mattrguerra/cortexmainPrivate on GitHub, published here in full

pricing.rs

Language Rust5.9 KB160 lines

crates/cortex-core/src/pricing.rs

  1. //! Models and their prices.
  2. //!
  3. //! DeepInfra's `GET /v1/openai/models` needs no key and carries, per model,
  4. //! `metadata.pricing` in US dollars per million tokens (`input_tokens`,
  5. //! `output_tokens`, sometimes `cache_read_tokens`), the context length, and
  6. //! tags (`chat`, `vision`, `reasoning`, ...). That list is an extension to the
  7. //! OpenAI format rather than a documented contract, so a dated snapshot ships
  8. //! with the app as a fallback and the interface says which one is in use.
  9. //! Local servers list bare model ids; they cost nothing.
  10. use serde::{Deserialize, Serialize};
  11. use serde_json::Value;
  12. use ts_rs::TS;
  13. use crate::types::Prices;
  14. /// Anything above this per million tokens is treated as a parsing mistake.
  15. const PRICE_CEILING: f64 = 1000.0;
  16. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
  17. #[ts(export)]
  18. pub struct ModelInfo {
  19. pub provider_id: String,
  20. pub id: String,
  21. pub prices: Prices,
  22. #[ts(type = "number | null")]
  23. pub context_length: Option<i64>,
  24. #[ts(type = "number | null")]
  25. pub max_tokens: Option<i64>,
  26. pub tags: Vec<String>,
  27. }
  28. impl ModelInfo {
  29. pub fn has_tag(&self, tag: &str) -> bool {
  30. self.tags.iter().any(|t| t == tag)
  31. }
  32. pub fn vision(&self) -> bool {
  33. self.has_tag("vision") || self.has_tag("vlm")
  34. }
  35. }
  36. fn price(v: Option<&Value>) -> Option<f64> {
  37. let p = v?.as_f64()?;
  38. (p.is_finite() && (0.0..=PRICE_CEILING).contains(&p)).then_some(p)
  39. }
  40. /// DeepInfra's list (live or snapshot): chat models with token prices only.
  41. pub fn parse_priced_models(provider_id: &str, v: &Value) -> Vec<ModelInfo> {
  42. let Some(data) = v.get("data").and_then(Value::as_array) else { return vec![] };
  43. let mut out: Vec<ModelInfo> = data
  44. .iter()
  45. .filter_map(|m| {
  46. let id = m.get("id")?.as_str()?.to_string();
  47. let meta = m.get("metadata")?;
  48. let tags: Vec<String> = meta
  49. .get("tags")
  50. .and_then(Value::as_array)
  51. .map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_string)).collect())
  52. .unwrap_or_default();
  53. if !tags.iter().any(|t| t == "chat") {
  54. return None;
  55. }
  56. let p = meta.get("pricing")?;
  57. let prices = Prices {
  58. input_per_m: price(p.get("input_tokens"))?,
  59. output_per_m: price(p.get("output_tokens"))?,
  60. cache_read_per_m: price(p.get("cache_read_tokens")),
  61. };
  62. Some(ModelInfo {
  63. provider_id: provider_id.to_string(),
  64. id,
  65. prices,
  66. context_length: meta.get("context_length").and_then(Value::as_i64),
  67. max_tokens: meta.get("max_tokens").and_then(Value::as_i64),
  68. tags,
  69. })
  70. })
  71. .collect();
  72. out.sort_by_key(|m| m.id.to_lowercase());
  73. out
  74. }
  75. /// A local server's `{"data": [{"id": ...}]}`: every model free.
  76. pub fn parse_local_models(provider_id: &str, v: &Value) -> Vec<ModelInfo> {
  77. let Some(data) = v.get("data").and_then(Value::as_array) else { return vec![] };
  78. let mut out: Vec<ModelInfo> = data
  79. .iter()
  80. .filter_map(|m| m.get("id")?.as_str())
  81. .map(|id| ModelInfo {
  82. provider_id: provider_id.to_string(),
  83. id: id.to_string(),
  84. prices: Prices::default(),
  85. context_length: None,
  86. max_tokens: None,
  87. tags: vec!["chat".into(), "local".into()],
  88. })
  89. .collect();
  90. out.sort_by(|a, b| a.id.cmp(&b.id));
  91. out
  92. }
  93. /// The bundled fallback, written by `scripts/snapshot-pricing.mjs`.
  94. pub const SNAPSHOT_JSON: &str = include_str!("../data/pricing-snapshot.json");
  95. pub struct Snapshot {
  96. pub fetched_at: String,
  97. pub models: Vec<ModelInfo>,
  98. }
  99. pub fn snapshot(provider_id: &str) -> Snapshot {
  100. let v: Value = serde_json::from_str(SNAPSHOT_JSON).expect("bundled pricing snapshot is valid JSON");
  101. Snapshot {
  102. fetched_at: v.get("fetched_at").and_then(Value::as_str).unwrap_or("unknown").to_string(),
  103. models: parse_priced_models(provider_id, &v),
  104. }
  105. }
  106. #[cfg(test)]
  107. mod tests {
  108. use super::*;
  109. #[test]
  110. fn keeps_priced_chat_models_only() {
  111. let models = parse_priced_models("deepinfra", &standin::models());
  112. let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
  113. assert!(ids.contains(&"standin/echo"));
  114. assert!(!ids.contains(&"standin/embed") && !ids.contains(&"standin/image"));
  115. let cached = models.iter().find(|m| m.id == "standin/cached").unwrap();
  116. assert_eq!(cached.prices.cache_read_per_m, Some(0.1));
  117. assert!(models.iter().find(|m| m.id == "standin/vision").unwrap().vision());
  118. }
  119. #[test]
  120. fn rejects_absurd_prices() {
  121. let v = serde_json::json!({"data": [
  122. {"id": "a", "metadata": {"tags": ["chat"], "pricing": {"input_tokens": 5000.0, "output_tokens": 1.0}}},
  123. {"id": "b", "metadata": {"tags": ["chat"], "pricing": {"input_tokens": -1.0, "output_tokens": 1.0}}},
  124. {"id": "c", "metadata": {"tags": ["chat"], "pricing": {"input_tokens": 0.1}}},
  125. {"id": "d", "metadata": {"tags": ["chat"], "pricing": {"input_tokens": 0.1, "output_tokens": 0.2}}}
  126. ]});
  127. let ids: Vec<String> = parse_priced_models("p", &v).into_iter().map(|m| m.id).collect();
  128. assert_eq!(ids, ["d"]);
  129. }
  130. #[test]
  131. fn local_models_are_free() {
  132. let v = serde_json::json!({"object": "list", "data": [{"id": "qwen3:8b"}, {"id": "llama3.2"}]});
  133. let m = parse_local_models("ollama", &v);
  134. assert_eq!(m[0].id, "llama3.2");
  135. assert_eq!(m[0].prices, Prices::default());
  136. }
  137. #[test]
  138. fn bundled_snapshot_parses() {
  139. let s = snapshot("deepinfra");
  140. assert!(s.models.len() > 20, "snapshot has {} chat models", s.models.len());
  141. assert!(s.fetched_at.starts_with("20"));
  142. assert!(s.models.iter().all(|m| m.has_tag("chat")));
  143. }
  144. }