use anyhow::{Context, anyhow, bail};
use serde::Deserialize;
use std::time::Duration;
use crate::util::http::install_ring_provider;
use crate::util::truncate;
pub(crate) struct DiscoveryClient {
client: reqwest::Client,
key: String,
base: String,
}
impl DiscoveryClient {
#[must_use]
pub(crate) fn new(key: String) -> Self {
install_ring_provider();
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.build()
.expect("failed to build OpenRouter discovery HTTP client");
Self {
client,
key,
base: "https://openrouter.ai/api/v1".to_string(),
}
}
fn endpoints_path(&self, model_id: &str) -> anyhow::Result<String> {
let (author, slug) = model_id.split_once('/').unwrap_or((model_id, ""));
let mut url = reqwest::Url::parse(&format!("{}/models", self.base))
.with_context(|| format!("invalid OpenRouter base URL '{}'", self.base))?;
url.path_segments_mut()
.map_err(|()| anyhow!("cannot URL-encode model id '{model_id}'"))?
.push(author)
.push(slug)
.push("endpoints");
Ok(url.to_string())
}
async fn fetch_json(&self, path: &str) -> anyhow::Result<serde_json::Value> {
let url = if path.starts_with("http") {
path.to_string()
} else {
format!("{}{}", self.base, path)
};
let resp = self
.client
.get(&url)
.bearer_auth(&self.key)
.send()
.await
.with_context(|| format!("OpenRouter request failed: GET {url}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
let detail = truncate(body.trim(), 500);
bail!(
"OpenRouter GET {url} failed: HTTP {status}{}",
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
);
}
resp.json::<serde_json::Value>()
.await
.with_context(|| format!("OpenRouter GET {url} returned invalid JSON"))
}
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ModelCatalogEntry {
pub id: String,
#[serde(default)]
pub canonical_slug: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub context_length: Option<i64>,
#[serde(default)]
pub supported_parameters: Option<Vec<String>>,
#[serde(default)]
pub reasoning: Option<ModelReasoning>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct Pricing {
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub completion: Option<String>,
#[serde(default)]
pub request: Option<String>,
#[serde(default)]
pub input_cache_read: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ModelReasoning {
#[serde(default)]
pub supported_efforts: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct EndpointsResponse {
pub data: EndpointsData,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct EndpointsData {
#[serde(default)]
pub endpoints: Vec<EndpointInfo>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct EndpointInfo {
pub tag: String,
pub name: String,
pub provider_name: String,
#[serde(default)]
pub context_length: Option<i64>,
#[serde(default)]
pub quantization: Option<String>,
#[serde(default, deserialize_with = "deserialize_status")]
pub status: Option<String>,
#[serde(default)]
pub supports_implicit_caching: Option<bool>,
#[serde(default)]
pub pricing: Option<Pricing>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct KeyInfo {
#[serde(default)]
pub limit: Option<f64>,
#[serde(default)]
pub limit_remaining: Option<f64>,
#[serde(default)]
pub limit_reset: Option<String>,
#[serde(default)]
pub is_free_tier: Option<bool>,
#[serde(default)]
pub label: Option<String>,
}
#[must_use]
pub(crate) fn parse_price(s: &str) -> Option<f64> {
let t = s.trim();
if t.is_empty() {
return None;
}
t.parse::<f64>().ok()
}
#[allow(
clippy::unnecessary_wraps,
reason = "Result is the deserialize_with contract"
)]
fn deserialize_status<'de, D>(de: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(serde_json::Value::deserialize(de)
.ok()
.and_then(|v| match v {
serde_json::Value::String(s) => Some(s),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
}))
}
fn parse_models(v: &serde_json::Value) -> anyhow::Result<Vec<ModelCatalogEntry>> {
let data = v
.get("data")
.ok_or_else(|| anyhow!("OpenRouter models response missing 'data'"))?;
serde_json::from_value(data.clone())
.with_context(|| "OpenRouter models response could not be parsed")
}
fn parse_endpoints(v: &serde_json::Value, model_id: &str) -> anyhow::Result<EndpointsResponse> {
serde_json::from_value(v.clone()).with_context(|| {
format!("OpenRouter endpoints response for '{model_id}' could not be parsed")
})
}
fn parse_key(v: &serde_json::Value) -> anyhow::Result<KeyInfo> {
let data = v
.get("data")
.ok_or_else(|| anyhow!("OpenRouter key response missing 'data'"))?;
serde_json::from_value(data.clone())
.with_context(|| "OpenRouter key response could not be parsed")
}
pub(crate) struct DiscoverySnapshot {
pub fetched_at: String,
pub catalog: Vec<ModelCatalogEntry>,
pub endpoints: EndpointsResponse,
pub key: KeyInfo,
pub raw_models_json: serde_json::Value,
pub raw_endpoints_json: serde_json::Value,
pub raw_key_json: serde_json::Value,
}
pub(crate) async fn discover(
client: &DiscoveryClient,
model: &str,
) -> anyhow::Result<DiscoverySnapshot> {
let fetched_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let raw_models_json = client.fetch_json("/models").await?;
let catalog = parse_models(&raw_models_json)?;
let exact_match = catalog.iter().any(|e| e.id == model);
let raw_endpoints_json = match client.fetch_json(&client.endpoints_path(model)?).await {
Ok(v) => v,
Err(e) if !exact_match => {
return Err(anyhow!(
"model '{model}' was not found in the OpenRouter catalog and its \
endpoints lookup failed: {e:#}{}",
suggestions_text(&catalog, model)
));
}
Err(e) => return Err(e),
};
let endpoints = parse_endpoints(&raw_endpoints_json, model)?;
let raw_key_json = client.fetch_json("/key").await?;
let key = parse_key(&raw_key_json)?;
Ok(DiscoverySnapshot {
fetched_at,
catalog,
endpoints,
key,
raw_models_json,
raw_endpoints_json,
raw_key_json,
})
}
fn suggestions_text(catalog: &[ModelCatalogEntry], model: &str) -> String {
let suggestions: Vec<&str> = catalog
.iter()
.map(|e| e.id.as_str())
.filter(|id| !id.is_empty() && (id.contains(model) || model.contains(id)))
.take(5)
.collect();
if suggestions.is_empty() {
return String::new();
}
format!(
" Did you mean: {}?",
suggestions
.iter()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_price_handles_edge_cases() {
assert_eq!(parse_price(""), None);
assert_eq!(parse_price(" "), None);
assert_eq!(parse_price("0"), Some(0.0));
assert_eq!(parse_price("0.00000028"), Some(0.000_000_28));
assert_eq!(parse_price("abc"), None);
assert_eq!(parse_price("1e-5"), Some(0.00001));
}
#[test]
fn parses_endpoints_response_fixture() {
let json = serde_json::json!({
"data": {
"id": "deepseek/deepseek-v4-flash-0731",
"name": "DeepSeek V4 Flash",
"created": 1_752_000_000,
"endpoints": [
{
"context_length": 163_840,
"max_completion_tokens": 8192,
"max_prompt_tokens": null,
"model_id": "deepseek/deepseek-v4-flash-0731",
"model_name": "DeepSeek V4 Flash",
"name": "StreamLake",
"provider_name": "StreamLake",
"pricing": {
"prompt": "0.00000028",
"completion": "0.00000028",
"request": "0",
"input_cache_read": "0.00000009"
},
"quantization": "fp8",
"status": "0",
"supported_parameters": ["tools", "tool_choice"],
"supports_implicit_caching": true,
"tag": "streamlake/fp8",
"latency_last_30m": {"p50": 1234.5},
"uptime_last_1d": 99.5,
"uptime_last_30m": null,
"uptime_last_5m": null,
"extra_unknown_field": "ignored"
}
]
}
});
let parsed: EndpointsResponse = serde_json::from_value(json).expect("fixture must parse");
let ep = &parsed.data.endpoints[0];
assert_eq!(ep.tag, "streamlake/fp8");
assert_eq!(ep.name, "StreamLake");
assert_eq!(ep.provider_name, "StreamLake");
assert_eq!(ep.context_length, Some(163_840));
assert_eq!(ep.quantization.as_deref(), Some("fp8"));
assert_eq!(ep.status.as_deref(), Some("0"));
assert_eq!(ep.supports_implicit_caching, Some(true));
let pricing = ep.pricing.as_ref().expect("pricing present");
assert_eq!(pricing.prompt.as_deref(), Some("0.00000028"));
assert_eq!(pricing.input_cache_read.as_deref(), Some("0.00000009"));
assert_eq!(pricing.request.as_deref(), Some("0"));
}
#[test]
fn parses_live_integer_status_shape() {
let json = serde_json::json!({
"data": {
"id": "deepseek/deepseek-v4-flash-0731",
"endpoints": [
{
"tag": "streamlake/fp8",
"name": "StreamLake",
"provider_name": "StreamLake",
"status": 0,
"quantization": "fp8",
"context_length": 1_048_576
},
{
"tag": "decart/fp4",
"name": "Decart",
"provider_name": "Decart",
"status": -2,
"quantization": "fp4",
"context_length": 1_048_576
},
{
"tag": "weird/type",
"name": "Weird",
"provider_name": "Weird",
"status": {"nested": true},
"context_length": 1_048_576
}
]
}
});
let parsed: EndpointsResponse = serde_json::from_value(json).expect("fixture must parse");
assert_eq!(parsed.data.endpoints[0].status.as_deref(), Some("0"));
assert_eq!(parsed.data.endpoints[1].status.as_deref(), Some("-2"));
assert_eq!(parsed.data.endpoints[2].status, None);
}
#[test]
fn endpoints_path_builds_encoded_segments() {
install_ring_provider();
let client = DiscoveryClient {
client: reqwest::Client::new(),
key: "k".to_string(),
base: "https://openrouter.ai/api/v1".to_string(),
};
assert_eq!(
client
.endpoints_path("deepseek/deepseek-v4-flash-0731")
.unwrap(),
"https://openrouter.ai/api/v1/models/deepseek/deepseek-v4-flash-0731/endpoints"
);
}
}