Skip to main content

everruns_openrouter/
types.rs

1// OpenRouter Models API Types
2//
3// OpenRouter exposes the same OpenAI-compatible surface (`/responses`,
4// `/chat/completions`) but its `/models` endpoint returns much richer metadata
5// than OpenAI's bare `{id, created, owned_by}`. In particular it advertises a
6// `supported_parameters` array (e.g. `reasoning`, `tools`, `response_format`)
7// that lets us derive a capability profile at discovery time — most OpenRouter
8// models (NVIDIA Nemotron, DeepSeek, etc.) have no hardcoded profile, so without
9// this the UI cannot tell they support reasoning and hides the effort selector.
10
11use serde::Deserialize;
12
13/// Response from OpenRouter's `/api/v1/models` endpoint.
14#[derive(Debug, Clone, Deserialize)]
15pub struct OpenRouterModelsResponse {
16    pub data: Vec<OpenRouterModelInfo>,
17}
18
19/// Individual model entry from OpenRouter's models API.
20///
21/// Only the fields we map are modeled; unknown fields are ignored so the parse
22/// is resilient to OpenRouter adding metadata over time.
23#[derive(Debug, Clone, Deserialize)]
24pub struct OpenRouterModelInfo {
25    /// Fully qualified model id (e.g. `nvidia/nemotron-3-super-120b-a12b`).
26    pub id: String,
27    /// Canonical model slug, when OpenRouter aliases the public id.
28    #[serde(default)]
29    pub canonical_slug: Option<String>,
30    /// Human-readable display name (e.g. "NVIDIA: Nemotron 3 Super").
31    #[serde(default)]
32    pub name: Option<String>,
33    /// Human-readable model description.
34    #[serde(default)]
35    pub description: Option<String>,
36    /// Unix timestamp of when the model was added.
37    #[serde(default)]
38    pub created: Option<i64>,
39    /// Maximum context window in tokens.
40    #[serde(default)]
41    pub context_length: Option<i64>,
42    /// Modality metadata (input/output modalities).
43    #[serde(default)]
44    pub architecture: Option<OpenRouterArchitecture>,
45    /// Per-token pricing reported by OpenRouter.
46    #[serde(default)]
47    pub pricing: Option<OpenRouterPricing>,
48    /// Top-provider limits (max completion tokens, effective context).
49    #[serde(default)]
50    pub top_provider: Option<OpenRouterTopProvider>,
51    /// Parameters the model/router accepts. The presence of `reasoning`
52    /// (or `reasoning_effort`) is what tells us reasoning is supported.
53    #[serde(default)]
54    pub supported_parameters: Vec<String>,
55    /// Provider-reported knowledge cutoff, when available.
56    #[serde(default)]
57    pub knowledge_cutoff: Option<String>,
58}
59
60/// Modality metadata from OpenRouter's `architecture` object.
61#[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/// Per-token pricing reported by OpenRouter's `pricing` object.
70#[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/// Per-request limits reported by the routed upstream provider.
83#[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    /// Whether the model supports a given parameter (case-insensitive).
93    fn supports(&self, param: &str) -> bool {
94        self.supported_parameters
95            .iter()
96            .any(|p| p.eq_ignore_ascii_case(param))
97    }
98
99    /// Whether this is a text-producing chat model. OpenRouter also lists
100    /// image-only / non-text-output models, which we exclude from discovery.
101    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            // No modality metadata — assume chat (OpenRouter's catalog is
108            // overwhelmingly text-output chat models).
109            _ => true,
110        }
111    }
112
113    /// Build a [`ModelProfile`](everruns_provider::model::ModelProfile) from
114    /// OpenRouter's advertised metadata.
115    pub fn to_discovered_profile(&self) -> everruns_provider::model::ModelProfile {
116        use everruns_provider::model::*;
117
118        // Reasoning is advertised via the `reasoning` parameter (modern unified
119        // control) or the legacy `reasoning_effort` alias.
120        let reasoning = self.supports("reasoning") || self.supports("reasoning_effort");
121        let reasoning_effort = reasoning.then(openrouter_effort_config);
122
123        // Modalities → attachment support.
124        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        // Prefer the routed provider's effective context window when present.
152        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                // OpenRouter commonly omits a separate output cap
167                // (`max_completion_tokens: null`). Fall back to the context
168                // window — the model's theoretical max output — rather than
169                // emitting a misleading `0` that renders as "Output: 0".
170                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
224/// Saturating `i64` → `i32` conversion.
225///
226/// OpenRouter reports token counts as `i64`, while `ModelLimits` stores
227/// `i32`. A raw `as` cast wraps on overflow (producing negative limits); this
228/// clamps into range instead. Negative inputs clamp to `0`.
229fn 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
237/// Standard low/medium/high effort config for OpenRouter reasoning models.
238///
239/// OpenRouter normalizes `reasoning.effort` ∈ {low, medium, high} across upstream
240/// providers, so we expose those three with `medium` as the default.
241fn 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    // Trimmed copy of the live `nvidia/nemotron-3-super-120b-a12b` entry from
268    // OpenRouter's /api/v1/models response.
269    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        // Capabilities derived from supported_parameters.
320        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        // Prefer the routed provider's effective context window.
355        let limits = profile.limits.expect("limits");
356        assert_eq!(limits.context, 262144);
357        // max_completion_tokens is null here, so output falls back to context
358        // rather than a misleading 0.
359        assert_eq!(limits.output, 262144);
360    }
361
362    #[test]
363    fn oversized_token_counts_saturate_instead_of_wrapping() {
364        // Values beyond i32::MAX must clamp, not wrap to a negative number.
365        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}