Skip to main content

synaps_cli/runtime/openai/catalog/
openrouter.rs

1//! OpenRouter `/models` parser and live fetch.
2
3use serde::Deserialize;
4
5use super::{CatalogModel, CatalogProviderKind, CatalogSource, Modality, PricingSummary, ReasoningSupport};
6
7#[derive(Debug, Deserialize)]
8struct OpenRouterModelsResponse {
9    data: Vec<OpenRouterModelItem>,
10}
11
12#[derive(Debug, Deserialize)]
13struct OpenRouterModelItem {
14    id: String,
15    #[serde(default)]
16    name: Option<String>,
17    #[serde(default)]
18    context_length: Option<u64>,
19    #[serde(default)]
20    architecture: Option<OpenRouterArchitecture>,
21    #[serde(default)]
22    pricing: Option<OpenRouterPricing>,
23    #[serde(default)]
24    top_provider: Option<OpenRouterTopProvider>,
25    #[serde(default)]
26    supported_parameters: Vec<String>,
27}
28
29#[derive(Debug, Deserialize)]
30struct OpenRouterArchitecture {
31    #[serde(default)]
32    input_modalities: Vec<String>,
33}
34
35#[derive(Debug, Deserialize)]
36struct OpenRouterPricing {
37    #[serde(default)]
38    prompt: Option<String>,
39    #[serde(default)]
40    completion: Option<String>,
41    #[serde(default)]
42    internal_reasoning: Option<String>,
43}
44
45#[derive(Debug, Deserialize)]
46struct OpenRouterTopProvider {
47    #[serde(default)]
48    max_completion_tokens: Option<u64>,
49}
50
51/// Parse an OpenRouter `/models` JSON body into CatalogModel entries.
52/// Pure function — no network I/O — safe to unit-test with fixtures.
53pub fn parse_openrouter_catalog_models(body: &str) -> Result<Vec<CatalogModel>, serde_json::Error> {
54    let resp: OpenRouterModelsResponse = serde_json::from_str(body)?;
55    Ok(resp
56        .data
57        .into_iter()
58        .filter_map(|item| {
59            let mut m = CatalogModel::new("openrouter", "OpenRouter", item.id)?;
60            m.provider_kind = CatalogProviderKind::OpenRouter;
61            m.label = item.name.filter(|n| !n.trim().is_empty());
62            m.context_tokens = item.context_length;
63            m.max_output_tokens = item.top_provider.as_ref().and_then(|p| p.max_completion_tokens);
64
65            let mods: Vec<Modality> = item
66                .architecture
67                .map(|a| a.input_modalities.iter().map(|s| Modality::from_str(s)).collect())
68                .unwrap_or_else(|| vec![Modality::Text]);
69            m.input_modalities = mods;
70
71            let pricing = item.pricing.map(|p| PricingSummary {
72                prompt:             p.prompt.filter(|v| !v.trim().is_empty()),
73                completion:         p.completion.filter(|v| !v.trim().is_empty()),
74                internal_reasoning: p.internal_reasoning.filter(|v| !v.trim().is_empty()),
75            }).unwrap_or_default();
76
77            let has = |param: &str| item.supported_parameters.iter().any(|p| p == param);
78            let internal_priced = pricing.has_internal_reasoning_cost();
79
80            m.reasoning = if has("verbosity") {
81                ReasoningSupport::AnthropicAdaptive { adaptive: true }
82            } else if has("reasoning") || has("include_reasoning") || has("reasoning_effort") || internal_priced {
83                ReasoningSupport::OpenRouter {
84                    include_reasoning: has("include_reasoning"),
85                    effort:            has("reasoning_effort"),
86                    verbosity:         false,
87                    internal_reasoning_priced: internal_priced,
88                }
89            } else {
90                ReasoningSupport::None
91            };
92
93            m.pricing = pricing;
94            m.source = CatalogSource::Live;
95            Some(m)
96        })
97        .collect())
98}