use async_trait::async_trait;
use reqwest::header;
use serde::Deserialize;
use tracing::{debug, error};
use crate::error::BaochuanError;
use crate::provider::{ChunkStream, Provider};
use crate::providers::openai_compat::OpenAICompatClient;
use crate::providers::sse::sse_to_chunks;
use crate::types::{ChatRequest, ChatResponse, ModelInfo};
const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
#[derive(Deserialize)]
struct OpenRouterModelList {
data: Vec<OpenRouterModel>,
}
#[derive(Deserialize)]
struct OpenRouterModel {
id: String,
name: Option<String>,
context_length: Option<u32>,
}
pub struct OpenRouterProvider {
inner: OpenAICompatClient,
site_url: Option<String>,
site_name: Option<String>,
}
impl OpenRouterProvider {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
inner: OpenAICompatClient::with_key(api_key, DEFAULT_BASE_URL),
site_url: None,
site_name: None,
}
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.inner.base_url = base_url.into();
self
}
pub fn site_url(mut self, url: impl Into<String>) -> Self {
self.site_url = Some(url.into());
self
}
pub fn site_name(mut self, name: impl Into<String>) -> Self {
self.site_name = Some(name.into());
self
}
fn apply_headers(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(ref url) = self.site_url {
builder = builder.header("HTTP-Referer", url.as_str());
}
if let Some(ref name) = self.site_name {
builder = builder.header("X-Title", name.as_str());
}
builder
}
}
#[async_trait]
impl Provider for OpenRouterProvider {
fn name(&self) -> &str {
"openrouter"
}
async fn models(&self) -> Result<Vec<ModelInfo>, BaochuanError> {
let response = self
.inner
.auth(self.inner.client.get(self.inner.models_url()))
.header(header::CONTENT_TYPE, "application/json")
.send()
.await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(BaochuanError::Api { status: status.as_u16(), message: body });
}
let list: OpenRouterModelList = response.json().await?;
Ok(list.data.into_iter().map(|m| ModelInfo {
id: m.id,
owned_by: None,
context_length: m.context_length,
display_name: m.name,
}).collect())
}
async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse, BaochuanError> {
debug!(model = %request.model, "sending chat request to OpenRouter");
let response = self.apply_headers(
self.inner
.auth(self.inner.client.post(self.inner.chat_url()))
.header(header::CONTENT_TYPE, "application/json")
.json(request),
)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
error!(status = %status, body = %body, "OpenRouter API error");
return Err(BaochuanError::Api { status: status.as_u16(), message: body });
}
let chat_response: ChatResponse = response.json().await?;
debug!(id = %chat_response.id, "received OpenRouter response");
Ok(chat_response)
}
async fn stream_chat(&self, request: &ChatRequest) -> Result<ChunkStream, BaochuanError> {
debug!(model = %request.model, "starting streaming chat request to OpenRouter");
let mut body = serde_json::to_value(request)?;
body["stream"] = serde_json::Value::Bool(true);
let response = self.apply_headers(
self.inner
.auth(self.inner.client.post(self.inner.chat_url()))
.header(header::CONTENT_TYPE, "application/json")
.json(&body),
)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
error!(status = %status, body = %body, "OpenRouter stream error");
return Err(BaochuanError::Api { status: status.as_u16(), message: body });
}
Ok(Box::pin(sse_to_chunks(response.bytes_stream())))
}
}