use adk_core::{AdkError, ErrorCategory, ErrorComponent};
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
use serde::{Deserialize, Serialize};
pub const OPENROUTER_API_BASE: &str = "https://openrouter.ai/api/v1";
const HTTP_REFERER_HEADER: &str = "http-referer";
const X_OPENROUTER_TITLE_HEADER: &str = "x-openrouter-title";
const X_TITLE_HEADER: &str = "x-title";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpenRouterApiMode {
#[default]
ChatCompletions,
Responses,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRouterConfig {
pub api_key: String,
pub model: String,
pub base_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_referer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default)]
pub default_api_mode: OpenRouterApiMode,
}
impl OpenRouterConfig {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
model: model.into(),
base_url: OPENROUTER_API_BASE.to_string(),
http_referer: None,
title: None,
default_api_mode: OpenRouterApiMode::default(),
}
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
pub fn with_http_referer(mut self, http_referer: impl Into<String>) -> Self {
self.http_referer = Some(http_referer.into());
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_default_api_mode(mut self, default_api_mode: OpenRouterApiMode) -> Self {
self.default_api_mode = default_api_mode;
self
}
pub fn effective_base_url(&self) -> &str {
self.base_url.trim_end_matches('/')
}
pub fn endpoint_url(&self, path: &str) -> String {
format!("{}/{}", self.effective_base_url(), path.trim_start_matches('/'))
}
pub fn default_headers(&self) -> Result<HeaderMap, AdkError> {
let mut headers = HeaderMap::new();
let mut authorization = HeaderValue::from_str(&format!("Bearer {}", self.api_key))
.map_err(|err| {
invalid_header_error(
"authorization",
"OpenRouter API key produced an invalid Authorization header",
)
.with_source(err)
})?;
authorization.set_sensitive(true);
headers.insert(AUTHORIZATION, authorization);
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
if let Some(http_referer) = &self.http_referer {
headers.insert(
HeaderName::from_static(HTTP_REFERER_HEADER),
HeaderValue::from_str(http_referer).map_err(|err| {
invalid_header_error(
"http_referer",
"OpenRouter HTTP-Referer header contains invalid characters",
)
.with_source(err)
})?,
);
}
if let Some(title) = &self.title {
let title_value = HeaderValue::from_str(title).map_err(|err| {
invalid_header_error("title", "OpenRouter title header contains invalid characters")
.with_source(err)
})?;
headers.insert(HeaderName::from_static(X_OPENROUTER_TITLE_HEADER), title_value.clone());
headers.insert(HeaderName::from_static(X_TITLE_HEADER), title_value);
}
Ok(headers)
}
}
fn invalid_header_error(field: &'static str, message: &'static str) -> AdkError {
AdkError::new(
ErrorComponent::Model,
ErrorCategory::InvalidInput,
"model.openrouter.invalid_header",
format!("{message}: {field}"),
)
.with_provider("openrouter")
}
#[cfg(test)]
mod tests {
use super::{
HTTP_REFERER_HEADER, OpenRouterApiMode, OpenRouterConfig, X_OPENROUTER_TITLE_HEADER,
X_TITLE_HEADER,
};
use reqwest::header::{AUTHORIZATION, HeaderName};
#[test]
fn default_headers_include_authorization_and_attribution_headers() {
let config = OpenRouterConfig::new("sk-or-test", "openai/gpt-5.2")
.with_http_referer("https://example.com")
.with_title("Example App");
let headers = config.default_headers().expect("headers should build");
assert_eq!(
headers.get(AUTHORIZATION).and_then(|value| value.to_str().ok()),
Some("Bearer sk-or-test")
);
assert_eq!(
headers
.get(HeaderName::from_static(HTTP_REFERER_HEADER))
.and_then(|value| value.to_str().ok()),
Some("https://example.com")
);
assert_eq!(
headers
.get(HeaderName::from_static(X_OPENROUTER_TITLE_HEADER))
.and_then(|value| value.to_str().ok()),
Some("Example App")
);
assert_eq!(
headers
.get(HeaderName::from_static(X_TITLE_HEADER))
.and_then(|value| value.to_str().ok()),
Some("Example App")
);
}
#[test]
fn endpoint_url_normalizes_trailing_slashes() {
let config = OpenRouterConfig::new("sk-or-test", "openai/gpt-5.2")
.with_base_url("https://openrouter.ai/api/v1/");
assert_eq!(
config.endpoint_url("/chat/completions"),
"https://openrouter.ai/api/v1/chat/completions"
);
}
#[test]
fn config_builder_sets_default_api_mode() {
let config = OpenRouterConfig::new("sk-or-test", "openai/gpt-5.2")
.with_default_api_mode(OpenRouterApiMode::Responses);
assert_eq!(config.default_api_mode, OpenRouterApiMode::Responses);
}
}