Skip to main content

aither_models/
convert.rs

1//! Shared conversion logic for `LiteLLM` and `OpenRouter` JSON.
2//!
3//! This module is included via `#[path]` in build.rs so the same parsing
4//! logic runs at compile time and runtime. It depends only on `serde`,
5//! `serde_json`, and sibling types.
6
7use serde::Deserialize;
8
9/// Raw `LiteLLM` entry as it appears in the JSON.
10#[derive(Debug, Deserialize)]
11pub struct RawLiteLLMEntry {
12    /// Provider slug as `LiteLLM` reports it.
13    #[serde(default)]
14    pub litellm_provider: Option<String>,
15    /// What the model does: `chat`, `embedding`, `image_generation`, and so on.
16    #[serde(default)]
17    pub mode: Option<String>,
18    /// Largest prompt the model accepts, in tokens.
19    #[serde(default)]
20    pub max_input_tokens: Option<u32>,
21    /// Largest completion the model will produce, in tokens.
22    #[serde(default)]
23    pub max_output_tokens: Option<u32>,
24    /// Combined limit, used when the input/output split is unknown.
25    #[serde(default)]
26    pub max_tokens: Option<u32>,
27    /// USD charged per input token.
28    #[serde(default)]
29    pub input_cost_per_token: Option<f64>,
30    /// USD charged per output token.
31    #[serde(default)]
32    pub output_cost_per_token: Option<f64>,
33    /// USD per input token served from the prompt cache.
34    #[serde(default)]
35    pub cache_read_input_token_cost: Option<f64>,
36    /// USD per input token written to the prompt cache.
37    #[serde(default)]
38    pub cache_creation_input_token_cost: Option<f64>,
39    /// USD per reasoning token, where billed separately.
40    #[serde(default)]
41    pub output_cost_per_reasoning_token: Option<f64>,
42    /// USD per audio input token.
43    #[serde(default)]
44    pub input_cost_per_audio_token: Option<f64>,
45    /// Date the provider retires the model, as `YYYY-MM-DD`.
46    #[serde(default)]
47    pub deprecation_date: Option<String>,
48    // supports_* flags
49    /// Whether the model can call tools.
50    #[serde(default)]
51    pub supports_function_calling: Option<bool>,
52    /// Whether the model accepts images.
53    #[serde(default)]
54    pub supports_vision: Option<bool>,
55    /// Whether the model accepts audio.
56    #[serde(default)]
57    pub supports_audio_input: Option<bool>,
58    /// Whether the model can emit audio.
59    #[serde(default)]
60    pub supports_audio_output: Option<bool>,
61    /// Whether the model accepts video.
62    #[serde(default)]
63    pub supports_video_input: Option<bool>,
64    /// Whether the provider offers built-in web search.
65    #[serde(default)]
66    pub supports_web_search: Option<bool>,
67    /// Whether the model accepts PDFs directly.
68    #[serde(default)]
69    pub supports_pdf_input: Option<bool>,
70    /// Whether the model exposes reasoning or thinking output.
71    #[serde(default)]
72    pub supports_reasoning: Option<bool>,
73    /// Whether the model supports computer-use tooling.
74    #[serde(default)]
75    pub supports_computer_use: Option<bool>,
76    /// Whether the provider caches prompt prefixes.
77    #[serde(default)]
78    pub supports_prompt_caching: Option<bool>,
79    /// Whether an assistant turn may be prefilled.
80    #[serde(default)]
81    pub supports_assistant_prefill: Option<bool>,
82}
83
84/// Known provider prefix → canonical provider mapping.
85const PROVIDER_PREFIXES: &[(&str, &str)] = &[
86    ("openai/", "openai"),
87    ("anthropic/", "anthropic"),
88    ("gemini/", "gemini"),
89    ("deepseek/", "deepseek"),
90    ("xai/", "xai"),
91    ("mistral/", "mistral"),
92    ("meta-llama/", "meta"),
93    ("bedrock/", "bedrock"),
94    ("azure/", "azure"),
95    ("azure_ai/", "azure"),
96    ("vertex_ai/", "vertex_ai"),
97    ("groq/", "groq"),
98    ("together_ai/", "together_ai"),
99    ("fireworks_ai/", "fireworks_ai"),
100    ("replicate/", "replicate"),
101    ("cohere/", "cohere"),
102    ("cohere_chat/", "cohere"),
103    ("perplexity/", "perplexity"),
104    ("github_copilot/", "github_copilot"),
105    ("ollama/", "ollama"),
106    ("nvidia_nim/", "nvidia_nim"),
107    ("sambanova/", "sambanova"),
108    ("cerebras/", "cerebras"),
109    ("dashscope/", "alibaba"),
110];
111
112/// Strip known provider prefixes to get the canonical model ID.
113#[must_use]
114pub fn strip_provider_prefix(litellm_key: &str) -> &str {
115    for &(prefix, _) in PROVIDER_PREFIXES {
116        if let Some(rest) = litellm_key.strip_prefix(prefix) {
117            return rest;
118        }
119    }
120    litellm_key
121}
122
123/// The Claude models Anthropic's effort documentation lists for a
124/// per-message effort change (an effort-only `system` message under the
125/// `mid-conversation-output-config-2026-07-01` beta). `LiteLLM` carries no
126/// such flag, so it is curated here.
127const ANTHROPIC_PER_MESSAGE_EFFORT_MODELS: &[&str] = &[
128    "claude-fable-5-1",
129    "claude-mythos-5-1",
130    "claude-opus-5-5",
131    "claude-opus-5",
132    "claude-sonnet-5-5",
133    "claude-haiku-5-5",
134];
135
136/// The platforms Anthropic documents the per-message effort change on: the
137/// Claude API and Google Cloud.
138const ANTHROPIC_PER_MESSAGE_EFFORT_PLATFORMS: &[&str] = &["anthropic", "vertex_ai"];
139
140/// The first GPT generation `OpenAI` documents the Responses API
141/// `configuration_update` input item for; every later generation takes it.
142const OPENAI_FIRST_PER_MESSAGE_EFFORT_GENERATION: u32 = 6;
143
144/// Whether the vendor documents a per-message reasoning effort change for
145/// `canonical_id` on the platform `provider` names.
146#[must_use]
147pub fn takes_per_message_effort(provider: &str, canonical_id: &str) -> bool {
148    if ANTHROPIC_PER_MESSAGE_EFFORT_PLATFORMS.contains(&provider) {
149        // Vertex AI pins a version after `@`.
150        let model = canonical_id
151            .split_once('@')
152            .map_or(canonical_id, |(model, _)| model);
153        return ANTHROPIC_PER_MESSAGE_EFFORT_MODELS.contains(&model);
154    }
155    if provider == "openai" {
156        return gpt_generation(canonical_id)
157            .is_some_and(|generation| generation >= OPENAI_FIRST_PER_MESSAGE_EFFORT_GENERATION);
158    }
159    false
160}
161
162/// The major generation of a `gpt-<N>…` model id.
163fn gpt_generation(model: &str) -> Option<u32> {
164    let rest = model.strip_prefix("gpt-")?;
165    let digits = rest
166        .find(|c: char| !c.is_ascii_digit())
167        .unwrap_or(rest.len());
168    rest.get(..digits)?.parse().ok()
169}
170
171/// Map a `LiteLLM` `litellm_provider` string to a provider tag.
172#[must_use]
173pub fn parse_provider(provider_str: &str) -> String {
174    let mapped = match provider_str {
175        "openai" | "text-completion-openai" | "chatgpt" => "openai",
176        "anthropic" => "anthropic",
177        "gemini" | "palm" => "gemini",
178        "deepseek" => "deepseek",
179        "xai" => "xai",
180        "mistral" | "codestral" | "text-completion-codestral" => "mistral",
181        "meta_llama" => "meta",
182        s if s.starts_with("bedrock") => "bedrock",
183        "azure" | "azure_ai" | "azure_text" => "azure",
184        s if s.starts_with("vertex_ai") => "vertex_ai",
185        "groq" => "groq",
186        "together_ai" => "together_ai",
187        s if s.starts_with("fireworks_ai") => "fireworks_ai",
188        "replicate" => "replicate",
189        "cohere" | "cohere_chat" => "cohere",
190        "perplexity" => "perplexity",
191        "github_copilot" => "github_copilot",
192        "dashscope" => "alibaba",
193        other => return other.to_string(),
194    };
195    mapped.to_string()
196}
197
198/// Map a `LiteLLM` `mode` string to a mode tag.
199#[must_use]
200pub fn parse_mode(mode_str: &str) -> Option<&'static str> {
201    match mode_str {
202        "chat" | "responses" => Some("chat"),
203        "embedding" => Some("embedding"),
204        "image_generation" | "image_edit" => Some("image_generation"),
205        "audio_transcription" => Some("audio_transcription"),
206        "audio_speech" => Some("audio_speech"),
207        "rerank" => Some("rerank"),
208        "moderation" => Some("moderation"),
209        "completion" => Some("completion"),
210        "video_generation" => Some("video_generation"),
211        "search" | "vector_store" => Some("search"),
212        "ocr" => Some("ocr"),
213        _ => None,
214    }
215}
216
217/// Intermediate representation used by both build.rs and runtime.
218#[derive(Debug, Clone)]
219pub struct ConvertedEntry {
220    /// Model id exactly as `LiteLLM` keys it, provider prefix included.
221    pub litellm_id: String,
222    /// Model id with the provider prefix stripped.
223    pub canonical_id: String,
224    /// Canonical provider slug.
225    pub provider: String,
226    /// What the model does, as a canonical mode string.
227    pub mode: String,
228    /// Largest prompt the model accepts, in tokens.
229    pub max_input_tokens: Option<u32>,
230    /// Largest completion the model produces, in tokens.
231    pub max_output_tokens: Option<u32>,
232    /// USD per input token.
233    pub input_cost_per_token: f64,
234    /// USD per output token.
235    pub output_cost_per_token: f64,
236    /// USD per cached input token read.
237    pub cache_read_per_token: Option<f64>,
238    /// USD per input token written to the cache.
239    pub cache_write_per_token: Option<f64>,
240    /// USD per reasoning token, where billed separately.
241    pub reasoning_per_token: Option<f64>,
242    /// USD per image token.
243    pub image_per_token: Option<f64>,
244    /// Capability names this model advertises.
245    pub abilities: Vec<&'static str>,
246    /// Retirement date, as `YYYY-MM-DD`.
247    pub deprecation_date: Option<String>,
248}
249
250/// Convert a single `LiteLLM` JSON entry into our intermediate representation.
251#[must_use]
252pub fn convert_litellm_entry(key: &str, raw: &RawLiteLLMEntry) -> Option<ConvertedEntry> {
253    let provider_str = raw.litellm_provider.as_deref()?;
254    let mode_str = raw.mode.as_deref()?;
255    let mode = parse_mode(mode_str)?;
256    let provider = parse_provider(provider_str);
257    let canonical_id = strip_provider_prefix(key).to_string();
258
259    let max_input = raw.max_input_tokens;
260    let max_output = raw.max_output_tokens.or(raw.max_tokens);
261
262    let mut abilities = Vec::new();
263    if raw.supports_function_calling == Some(true) {
264        abilities.push("ToolUse");
265    }
266    if raw.supports_vision == Some(true) {
267        abilities.push("Vision");
268    }
269    if raw.supports_audio_input == Some(true) {
270        abilities.push("Audio");
271    }
272    if raw.supports_audio_output == Some(true) {
273        abilities.push("AudioOutput");
274    }
275    if raw.supports_video_input == Some(true) {
276        abilities.push("Video");
277    }
278    if raw.supports_web_search == Some(true) {
279        abilities.push("WebSearch");
280    }
281    if raw.supports_pdf_input == Some(true) {
282        abilities.push("Pdf");
283    }
284    if raw.supports_reasoning == Some(true) {
285        abilities.push("Reasoning");
286    }
287    if raw.supports_computer_use == Some(true) {
288        abilities.push("ComputerUse");
289    }
290    if raw.supports_prompt_caching == Some(true) {
291        abilities.push("PromptCaching");
292    }
293    if raw.supports_assistant_prefill == Some(true) {
294        abilities.push("AssistantPrefill");
295    }
296    if mode == "image_generation" {
297        abilities.push("ImageGeneration");
298    }
299    if takes_per_message_effort(&provider, &canonical_id) {
300        abilities.push("PerMessageEffort");
301    }
302
303    Some(ConvertedEntry {
304        litellm_id: key.to_string(),
305        canonical_id,
306        provider,
307        mode: mode.to_string(),
308        max_input_tokens: max_input,
309        max_output_tokens: max_output,
310        input_cost_per_token: raw.input_cost_per_token.unwrap_or(0.0),
311        output_cost_per_token: raw.output_cost_per_token.unwrap_or(0.0),
312        cache_read_per_token: raw.cache_read_input_token_cost,
313        cache_write_per_token: raw.cache_creation_input_token_cost,
314        reasoning_per_token: raw.output_cost_per_reasoning_token,
315        image_per_token: raw.input_cost_per_audio_token,
316        abilities,
317        deprecation_date: raw.deprecation_date.clone(),
318    })
319}
320
321/// Parse the full `LiteLLM` JSON blob and return converted entries.
322///
323/// # Panics
324///
325/// Panics if `json_bytes` is not a JSON object. The only caller supplies either
326/// the committed snapshot or a payload already validated by the caller.
327#[must_use]
328pub fn parse_litellm_json(json_bytes: &[u8]) -> Vec<ConvertedEntry> {
329    let map: serde_json::Map<String, serde_json::Value> =
330        serde_json::from_slice(json_bytes).expect("invalid LiteLLM JSON");
331
332    let mut entries = Vec::new();
333    for (key, value) in &map {
334        if key == "sample_spec" {
335            continue;
336        }
337        let Ok(raw) = serde_json::from_value::<RawLiteLLMEntry>(value.clone()) else {
338            continue;
339        };
340        if let Some(entry) = convert_litellm_entry(key, &raw) {
341            entries.push(entry);
342        }
343    }
344    entries
345}