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 Claude models whose Messages API takes a system message inside
137/// `messages`. `LiteLLM` carries no such flag, so it is curated here.
138const ANTHROPIC_MID_CONVERSATION_SYSTEM_MODELS: &[&str] = &[
139    "claude-opus-4-8",
140    "claude-fable-5",
141    "claude-fable-5-1",
142    "claude-mythos-5",
143    "claude-mythos-5-1",
144    "claude-opus-5",
145    "claude-opus-5-5",
146    "claude-sonnet-5-5",
147    "claude-haiku-5-5",
148];
149
150/// Whether the Claude API takes a mid-conversation system message for
151/// `canonical_id` on the platform `provider` names.
152#[must_use]
153pub fn takes_mid_conversation_system(provider: &str, canonical_id: &str) -> bool {
154    provider == "anthropic" && ANTHROPIC_MID_CONVERSATION_SYSTEM_MODELS.contains(&canonical_id)
155}
156
157/// The platforms Anthropic documents the per-message effort change on: the
158/// Claude API and Google Cloud.
159const ANTHROPIC_PER_MESSAGE_EFFORT_PLATFORMS: &[&str] = &["anthropic", "vertex_ai"];
160
161/// The first GPT generation `OpenAI` documents the Responses API
162/// `configuration_update` input item for; every later generation takes it.
163const OPENAI_FIRST_PER_MESSAGE_EFFORT_GENERATION: u32 = 6;
164
165/// Whether the vendor documents a per-message reasoning effort change for
166/// `canonical_id` on the platform `provider` names.
167#[must_use]
168pub fn takes_per_message_effort(provider: &str, canonical_id: &str) -> bool {
169    if ANTHROPIC_PER_MESSAGE_EFFORT_PLATFORMS.contains(&provider) {
170        // Vertex AI pins a version after `@`.
171        let model = canonical_id
172            .split_once('@')
173            .map_or(canonical_id, |(model, _)| model);
174        return ANTHROPIC_PER_MESSAGE_EFFORT_MODELS.contains(&model);
175    }
176    if provider == "openai" {
177        return gpt_generation(canonical_id)
178            .is_some_and(|generation| generation >= OPENAI_FIRST_PER_MESSAGE_EFFORT_GENERATION);
179    }
180    false
181}
182
183/// The major generation of a `gpt-<N>…` model id.
184fn gpt_generation(model: &str) -> Option<u32> {
185    let rest = model.strip_prefix("gpt-")?;
186    let digits = rest
187        .find(|c: char| !c.is_ascii_digit())
188        .unwrap_or(rest.len());
189    rest.get(..digits)?.parse().ok()
190}
191
192/// Map a `LiteLLM` `litellm_provider` string to a provider tag.
193#[must_use]
194pub fn parse_provider(provider_str: &str) -> String {
195    let mapped = match provider_str {
196        "openai" | "text-completion-openai" | "chatgpt" => "openai",
197        "anthropic" => "anthropic",
198        "gemini" | "palm" => "gemini",
199        "deepseek" => "deepseek",
200        "xai" => "xai",
201        "mistral" | "codestral" | "text-completion-codestral" => "mistral",
202        "meta_llama" => "meta",
203        s if s.starts_with("bedrock") => "bedrock",
204        "azure" | "azure_ai" | "azure_text" => "azure",
205        s if s.starts_with("vertex_ai") => "vertex_ai",
206        "groq" => "groq",
207        "together_ai" => "together_ai",
208        s if s.starts_with("fireworks_ai") => "fireworks_ai",
209        "replicate" => "replicate",
210        "cohere" | "cohere_chat" => "cohere",
211        "perplexity" => "perplexity",
212        "github_copilot" => "github_copilot",
213        "dashscope" => "alibaba",
214        other => return other.to_string(),
215    };
216    mapped.to_string()
217}
218
219/// Map a `LiteLLM` `mode` string to a mode tag.
220#[must_use]
221pub fn parse_mode(mode_str: &str) -> Option<&'static str> {
222    match mode_str {
223        "chat" | "responses" => Some("chat"),
224        "embedding" => Some("embedding"),
225        "image_generation" | "image_edit" => Some("image_generation"),
226        "audio_transcription" => Some("audio_transcription"),
227        "audio_speech" => Some("audio_speech"),
228        "rerank" => Some("rerank"),
229        "moderation" => Some("moderation"),
230        "completion" => Some("completion"),
231        "video_generation" => Some("video_generation"),
232        "search" | "vector_store" => Some("search"),
233        "ocr" => Some("ocr"),
234        _ => None,
235    }
236}
237
238/// Intermediate representation used by both build.rs and runtime.
239#[derive(Debug, Clone)]
240pub struct ConvertedEntry {
241    /// Model id exactly as `LiteLLM` keys it, provider prefix included.
242    pub litellm_id: String,
243    /// Model id with the provider prefix stripped.
244    pub canonical_id: String,
245    /// Canonical provider slug.
246    pub provider: String,
247    /// What the model does, as a canonical mode string.
248    pub mode: String,
249    /// Largest prompt the model accepts, in tokens.
250    pub max_input_tokens: Option<u32>,
251    /// Largest completion the model produces, in tokens.
252    pub max_output_tokens: Option<u32>,
253    /// USD per input token.
254    pub input_cost_per_token: f64,
255    /// USD per output token.
256    pub output_cost_per_token: f64,
257    /// USD per cached input token read.
258    pub cache_read_per_token: Option<f64>,
259    /// USD per input token written to the cache.
260    pub cache_write_per_token: Option<f64>,
261    /// USD per reasoning token, where billed separately.
262    pub reasoning_per_token: Option<f64>,
263    /// USD per image token.
264    pub image_per_token: Option<f64>,
265    /// Capability names this model advertises.
266    pub abilities: Vec<&'static str>,
267    /// Retirement date, as `YYYY-MM-DD`.
268    pub deprecation_date: Option<String>,
269}
270
271/// Convert a single `LiteLLM` JSON entry into our intermediate representation.
272#[must_use]
273pub fn convert_litellm_entry(key: &str, raw: &RawLiteLLMEntry) -> Option<ConvertedEntry> {
274    let provider_str = raw.litellm_provider.as_deref()?;
275    let mode_str = raw.mode.as_deref()?;
276    let mode = parse_mode(mode_str)?;
277    let provider = parse_provider(provider_str);
278    let canonical_id = strip_provider_prefix(key).to_string();
279
280    let max_input = raw.max_input_tokens;
281    let max_output = raw.max_output_tokens.or(raw.max_tokens);
282
283    let mut abilities = Vec::new();
284    if raw.supports_function_calling == Some(true) {
285        abilities.push("ToolUse");
286    }
287    if raw.supports_vision == Some(true) {
288        abilities.push("Vision");
289    }
290    if raw.supports_audio_input == Some(true) {
291        abilities.push("Audio");
292    }
293    if raw.supports_audio_output == Some(true) {
294        abilities.push("AudioOutput");
295    }
296    if raw.supports_video_input == Some(true) {
297        abilities.push("Video");
298    }
299    if raw.supports_web_search == Some(true) {
300        abilities.push("WebSearch");
301    }
302    if raw.supports_pdf_input == Some(true) {
303        abilities.push("Pdf");
304    }
305    if raw.supports_reasoning == Some(true) {
306        abilities.push("Reasoning");
307    }
308    if raw.supports_computer_use == Some(true) {
309        abilities.push("ComputerUse");
310    }
311    if raw.supports_prompt_caching == Some(true) {
312        abilities.push("PromptCaching");
313    }
314    if raw.supports_assistant_prefill == Some(true) {
315        abilities.push("AssistantPrefill");
316    }
317    if mode == "image_generation" {
318        abilities.push("ImageGeneration");
319    }
320    if takes_per_message_effort(&provider, &canonical_id) {
321        abilities.push("PerMessageEffort");
322    }
323    if takes_mid_conversation_system(&provider, &canonical_id) {
324        abilities.push("MidConversationSystem");
325    }
326
327    Some(ConvertedEntry {
328        litellm_id: key.to_string(),
329        canonical_id,
330        provider,
331        mode: mode.to_string(),
332        max_input_tokens: max_input,
333        max_output_tokens: max_output,
334        input_cost_per_token: raw.input_cost_per_token.unwrap_or(0.0),
335        output_cost_per_token: raw.output_cost_per_token.unwrap_or(0.0),
336        cache_read_per_token: raw.cache_read_input_token_cost,
337        cache_write_per_token: raw.cache_creation_input_token_cost,
338        reasoning_per_token: raw.output_cost_per_reasoning_token,
339        image_per_token: raw.input_cost_per_audio_token,
340        abilities,
341        deprecation_date: raw.deprecation_date.clone(),
342    })
343}
344
345/// Parse the full `LiteLLM` JSON blob and return converted entries.
346///
347/// # Panics
348///
349/// Panics if `json_bytes` is not a JSON object. The only caller supplies either
350/// the committed snapshot or a payload already validated by the caller.
351#[must_use]
352pub fn parse_litellm_json(json_bytes: &[u8]) -> Vec<ConvertedEntry> {
353    let map: serde_json::Map<String, serde_json::Value> =
354        serde_json::from_slice(json_bytes).expect("invalid LiteLLM JSON");
355
356    let mut entries = Vec::new();
357    for (key, value) in &map {
358        if key == "sample_spec" {
359            continue;
360        }
361        let Ok(raw) = serde_json::from_value::<RawLiteLLMEntry>(value.clone()) else {
362            continue;
363        };
364        if let Some(entry) = convert_litellm_entry(key, &raw) {
365            entries.push(entry);
366        }
367    }
368    entries
369}