1use serde::Deserialize;
12
13#[derive(Debug, Clone, Deserialize)]
15pub struct OpenRouterModelsResponse {
16 pub data: Vec<OpenRouterModelInfo>,
17}
18
19#[derive(Debug, Clone, Deserialize)]
24pub struct OpenRouterModelInfo {
25 pub id: String,
27 #[serde(default)]
29 pub canonical_slug: Option<String>,
30 #[serde(default)]
32 pub name: Option<String>,
33 #[serde(default)]
35 pub description: Option<String>,
36 #[serde(default)]
38 pub created: Option<i64>,
39 #[serde(default)]
41 pub context_length: Option<i64>,
42 #[serde(default)]
44 pub architecture: Option<OpenRouterArchitecture>,
45 #[serde(default)]
47 pub pricing: Option<OpenRouterPricing>,
48 #[serde(default)]
50 pub top_provider: Option<OpenRouterTopProvider>,
51 #[serde(default)]
54 pub supported_parameters: Vec<String>,
55 #[serde(default)]
57 pub knowledge_cutoff: Option<String>,
58}
59
60#[derive(Debug, Clone, Deserialize)]
62pub struct OpenRouterArchitecture {
63 #[serde(default)]
64 pub input_modalities: Vec<String>,
65 #[serde(default)]
66 pub output_modalities: Vec<String>,
67}
68
69#[derive(Debug, Clone, Deserialize)]
71pub struct OpenRouterPricing {
72 #[serde(default)]
73 pub prompt: Option<String>,
74 #[serde(default)]
75 pub completion: Option<String>,
76 #[serde(default)]
77 pub input_cache_read: Option<String>,
78 #[serde(default)]
79 pub cache_read: Option<String>,
80}
81
82#[derive(Debug, Clone, Deserialize)]
84pub struct OpenRouterTopProvider {
85 #[serde(default)]
86 pub context_length: Option<i64>,
87 #[serde(default)]
88 pub max_completion_tokens: Option<i64>,
89}
90
91impl OpenRouterModelInfo {
92 fn supports(&self, param: &str) -> bool {
94 self.supported_parameters
95 .iter()
96 .any(|p| p.eq_ignore_ascii_case(param))
97 }
98
99 pub fn is_chat_model(&self) -> bool {
102 match self.architecture.as_ref() {
103 Some(arch) if !arch.output_modalities.is_empty() => arch
104 .output_modalities
105 .iter()
106 .any(|m| m.eq_ignore_ascii_case("text")),
107 _ => true,
110 }
111 }
112
113 pub fn to_discovered_profile(&self) -> everruns_provider::model::ModelProfile {
116 use everruns_provider::model::*;
117
118 let reasoning = self.supports("reasoning") || self.supports("reasoning_effort");
121 let reasoning_effort = reasoning.then(openrouter_effort_config);
122
123 let input_modalities = self
125 .architecture
126 .as_ref()
127 .map(|a| a.input_modalities.as_slice())
128 .unwrap_or(&[]);
129 let has_modality = |name: &str| {
130 input_modalities
131 .iter()
132 .any(|m| m.eq_ignore_ascii_case(name))
133 };
134 let image_input = has_modality("image");
135 let pdf_input = has_modality("file") || has_modality("pdf");
136
137 let modalities = {
138 let mut input = vec![Modality::Text];
139 if image_input {
140 input.push(Modality::Image);
141 }
142 if pdf_input {
143 input.push(Modality::Pdf);
144 }
145 Some(ModelModalities {
146 input,
147 output: vec![Modality::Text],
148 })
149 };
150
151 let context = self
153 .top_provider
154 .as_ref()
155 .and_then(|t| t.context_length)
156 .or(self.context_length);
157 let max_output = self
158 .top_provider
159 .as_ref()
160 .and_then(|t| t.max_completion_tokens);
161 let limits = context.map(|ctx| {
162 let context = clamp_i64_to_i32(ctx);
163 ModelLimits {
164 context,
165 input: None,
166 output: max_output.map(clamp_i64_to_i32).unwrap_or(context),
171 max_media: None,
172 }
173 });
174
175 ModelProfile {
176 name: self.name.clone().unwrap_or_else(|| self.id.clone()),
177 family: self
178 .canonical_slug
179 .clone()
180 .unwrap_or_else(|| self.id.clone()),
181 description: self.description.clone(),
182 release_date: None,
183 last_updated: None,
184 attachment: image_input || pdf_input,
185 reasoning,
186 temperature: self.supports("temperature"),
187 knowledge: self.knowledge_cutoff.clone(),
188 tool_call: self.supports("tools") || self.supports("tool_choice"),
189 structured_output: self.supports("structured_outputs")
190 || self.supports("response_format"),
191 open_weights: false,
192 cost: self.pricing.as_ref().and_then(OpenRouterPricing::to_cost),
193 limits,
194 modalities,
195 reasoning_effort,
196 speed: None,
197 verbosity: None,
198 tool_search: false,
199 supported_parameters: self.supported_parameters.clone(),
200 supports_phases: false,
201 }
202 }
203}
204
205impl OpenRouterPricing {
206 fn to_cost(&self) -> Option<everruns_provider::model::ModelCost> {
207 let input = openrouter_price_per_million(self.prompt.as_deref()?)?;
208 let output = openrouter_price_per_million(self.completion.as_deref()?)?;
209 let cache_read = self
210 .input_cache_read
211 .as_deref()
212 .or(self.cache_read.as_deref())
213 .and_then(openrouter_price_per_million);
214
215 Some(everruns_provider::model::ModelCost {
216 input,
217 output,
218 cache_read,
219 cost_tiers: Vec::new(),
220 })
221 }
222}
223
224fn clamp_i64_to_i32(value: i64) -> i32 {
230 value.clamp(0, i32::MAX as i64) as i32
231}
232
233fn openrouter_price_per_million(value: &str) -> Option<f64> {
234 value.parse::<f64>().ok().map(|price| price * 1_000_000.0)
235}
236
237fn openrouter_effort_config() -> everruns_provider::model::ReasoningEffortConfig {
242 use everruns_provider::model::*;
243 ReasoningEffortConfig {
244 values: vec![
245 ReasoningEffortValue {
246 value: ReasoningEffort::Low,
247 name: "Low".into(),
248 },
249 ReasoningEffortValue {
250 value: ReasoningEffort::Medium,
251 name: "Medium".into(),
252 },
253 ReasoningEffortValue {
254 value: ReasoningEffort::High,
255 name: "High".into(),
256 },
257 ],
258 default: ReasoningEffort::Medium,
259 }
260}
261
262#[cfg(test)]
263mod openrouter_tests {
264 use super::*;
265 use everruns_provider::model::ReasoningEffort;
266
267 const NEMOTRON_JSON: &str = r#"{
270 "id": "nvidia/nemotron-3-super-120b-a12b",
271 "canonical_slug": "nvidia/nemotron-3-super-120b-a12b",
272 "name": "NVIDIA: Nemotron 3 Super",
273 "description": "A reasoning-capable chat model.",
274 "created": 1773245239,
275 "context_length": 1000000,
276 "architecture": {
277 "input_modalities": ["text"],
278 "output_modalities": ["text"]
279 },
280 "pricing": {
281 "prompt": "0.00000010",
282 "completion": "0.00000040",
283 "input_cache_read": "0.00000002"
284 },
285 "top_provider": { "context_length": 262144, "max_completion_tokens": null },
286 "supported_parameters": [
287 "include_reasoning", "max_tokens", "reasoning", "response_format",
288 "structured_outputs", "temperature", "tool_choice", "tools", "top_p"
289 ],
290 "knowledge_cutoff": "2025-01"
291 }"#;
292
293 fn parse(json: &str) -> OpenRouterModelInfo {
294 serde_json::from_str(json).expect("parse model")
295 }
296
297 #[test]
298 fn nemotron_profile_advertises_reasoning_with_effort_levels() {
299 let model = parse(NEMOTRON_JSON);
300 assert!(model.is_chat_model());
301
302 let profile = model.to_discovered_profile();
303 assert!(profile.reasoning, "Nemotron must be reasoning-capable");
304
305 let effort = profile
306 .reasoning_effort
307 .expect("reasoning models expose an effort config");
308 assert_eq!(effort.default, ReasoningEffort::Medium);
309 let values: Vec<_> = effort.values.iter().map(|v| v.value.clone()).collect();
310 assert_eq!(
311 values,
312 vec![
313 ReasoningEffort::Low,
314 ReasoningEffort::Medium,
315 ReasoningEffort::High
316 ]
317 );
318
319 assert!(profile.tool_call);
321 assert!(profile.structured_output);
322 assert!(profile.temperature);
323 assert_eq!(profile.name, "NVIDIA: Nemotron 3 Super");
324 assert_eq!(profile.family, "nvidia/nemotron-3-super-120b-a12b");
325 assert_eq!(
326 profile.description.as_deref(),
327 Some("A reasoning-capable chat model.")
328 );
329 assert_eq!(profile.release_date, None);
330 assert_eq!(profile.knowledge.as_deref(), Some("2025-01"));
331 let supported: Vec<_> = profile
332 .supported_parameters
333 .iter()
334 .map(String::as_str)
335 .collect();
336 assert_eq!(
337 supported,
338 vec![
339 "include_reasoning",
340 "max_tokens",
341 "reasoning",
342 "response_format",
343 "structured_outputs",
344 "temperature",
345 "tool_choice",
346 "tools",
347 "top_p"
348 ]
349 );
350 let cost = profile.cost.expect("cost");
351 assert!((cost.input - 0.1).abs() < 1e-9);
352 assert!((cost.output - 0.4).abs() < 1e-9);
353 assert!((cost.cache_read.expect("cache read") - 0.02).abs() < 1e-9);
354 let limits = profile.limits.expect("limits");
356 assert_eq!(limits.context, 262144);
357 assert_eq!(limits.output, 262144);
360 }
361
362 #[test]
363 fn oversized_token_counts_saturate_instead_of_wrapping() {
364 let model = parse(
366 r#"{
367 "id": "vendor/huge-context",
368 "context_length": 5000000000,
369 "architecture": { "output_modalities": ["text"] },
370 "supported_parameters": ["max_tokens"]
371 }"#,
372 );
373 let limits = model.to_discovered_profile().limits.expect("limits");
374 assert_eq!(limits.context, i32::MAX);
375 assert_eq!(limits.output, i32::MAX);
376 assert!(limits.output > 0);
377 }
378
379 #[test]
380 fn non_reasoning_model_has_no_effort_config() {
381 let model = parse(
382 r#"{
383 "id": "openai/gpt-4o-mini",
384 "architecture": { "output_modalities": ["text"] },
385 "supported_parameters": ["max_tokens", "temperature", "tools"]
386 }"#,
387 );
388 let profile = model.to_discovered_profile();
389 assert!(!profile.reasoning);
390 assert!(profile.reasoning_effort.is_none());
391 assert!(profile.tool_call);
392 }
393
394 #[test]
395 fn image_only_output_models_are_excluded() {
396 let model = parse(
397 r#"{
398 "id": "some/image-generator",
399 "architecture": { "output_modalities": ["image"] },
400 "supported_parameters": []
401 }"#,
402 );
403 assert!(!model.is_chat_model());
404 }
405
406 #[test]
407 fn legacy_reasoning_effort_alias_is_detected() {
408 let model = parse(
409 r#"{
410 "id": "vendor/legacy-reasoner",
411 "architecture": { "output_modalities": ["text"] },
412 "supported_parameters": ["reasoning_effort", "temperature"]
413 }"#,
414 );
415 let profile = model.to_discovered_profile();
416 assert!(profile.reasoning);
417 assert!(profile.reasoning_effort.is_some());
418 }
419}