use std::env;
use std::pin::Pin;
use async_trait::async_trait;
use futures::Stream;
use super::error::BackendError;
use super::openai_compat::{OpenAICompatConfig, OpenAICompatibleBackend};
use super::tokens;
use super::{Backend, Capability, ChatRequest, ChatResponse, ChatStream};
const API_KEY_ENV: &str = "OPENROUTER_API_KEY";
pub struct OpenRouterBackend {
inner: OpenAICompatibleBackend,
}
impl OpenRouterBackend {
pub fn from_env() -> Self {
Self::with_api_key(env::var(API_KEY_ENV).ok())
}
pub fn with_api_key(api_key: Option<String>) -> Self {
Self {
inner: OpenAICompatibleBackend::new(
OpenAICompatConfig::openrouter(),
api_key,
),
}
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.inner = self.inner.with_base_url(base_url);
self
}
pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
self.inner = self.inner.with_default_model(model);
self
}
pub fn inner(&self) -> &OpenAICompatibleBackend {
&self.inner
}
}
impl Default for OpenRouterBackend {
fn default() -> Self {
Self::from_env()
}
}
#[async_trait]
impl Backend for OpenRouterBackend {
fn name(&self) -> &str {
self.inner.name()
}
fn default_model(&self) -> &str {
self.inner.default_model()
}
async fn complete(&self, request: ChatRequest) -> Result<ChatResponse, BackendError> {
self.inner.complete(request).await
}
async fn stream(&self, request: ChatRequest) -> Result<ChatStream, BackendError> {
self.inner.stream(request).await
}
fn count_tokens(&self, model: &str, text: &str) -> usize {
let underlying = strip_provider_prefix(model);
tokens::count_tokens(underlying, text).count
}
fn supports(&self, capability: Capability, model: &str) -> bool {
match capability {
Capability::Vision => slug_supports_vision(model),
other => self.inner.supports(other, model),
}
}
}
fn strip_provider_prefix(model: &str) -> &str {
model.split_once('/').map(|(_, rest)| rest).unwrap_or(model)
}
fn slug_supports_vision(model: &str) -> bool {
let lc = model.to_lowercase();
let (provider, name) = match lc.split_once('/') {
Some((p, n)) => (p, n),
None => return false,
};
match provider {
"openai" => name.starts_with("gpt-4o"),
"anthropic" => name.starts_with("claude-"),
"google" => name.contains("1.5") || name.contains("2.0") || name.contains("2.5"),
"meta" | "meta-llama" => name.contains("llama-3.2-vision") || name.contains("llava"),
"qwen" => name.contains("vl"),
"microsoft" => name.contains("phi-3.5-vision") || name.contains("phi-4-vision"),
"zhipu" | "glm" | "z-ai" => name.starts_with("glm-4v"),
"mistralai" | "mistral" => name.contains("pixtral"),
_ => false,
}
}
pub fn from_env() -> OpenRouterBackend {
OpenRouterBackend::from_env()
}
pub fn with_api_key(api_key: Option<String>) -> OpenRouterBackend {
OpenRouterBackend::with_api_key(api_key)
}
#[allow(dead_code)]
type OpenRouterChatStream =
Pin<Box<dyn Stream<Item = Result<crate::backends::ChatChunk, BackendError>> + Send>>;
#[cfg(test)]
mod tests {
use super::*;
use crate::backends::openai_compat::build_request_body;
use crate::backends::Message;
fn req_with(messages: Vec<Message>) -> ChatRequest {
ChatRequest {
model: String::new(),
messages,
..Default::default()
}
}
#[test]
fn from_env_constructs_openrouter_backend() {
let b = OpenRouterBackend::from_env();
assert_eq!(b.name(), "openrouter");
assert_eq!(b.default_model(), "openai/gpt-4o-mini");
}
#[test]
fn module_factory_from_env_works() {
let b = from_env();
assert_eq!(b.name(), "openrouter");
}
#[test]
fn module_factory_with_api_key_explicit() {
let b = with_api_key(Some("sk-or-v1-test".into()));
assert_eq!(b.name(), "openrouter");
}
#[test]
fn with_default_model_overrides() {
let b = OpenRouterBackend::with_api_key(Some("k".into()))
.with_default_model("anthropic/claude-haiku-4-5");
assert_eq!(b.default_model(), "anthropic/claude-haiku-4-5");
}
#[test]
fn with_base_url_overrides_for_test_fixtures() {
let _b = OpenRouterBackend::with_api_key(Some("k".into()))
.with_base_url("http://localhost:9999");
}
#[test]
fn inner_accessor_returns_compat_backend() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert_eq!(b.inner().name(), "openrouter");
}
#[test]
fn default_constructs_via_from_env() {
let b = OpenRouterBackend::default();
assert_eq!(b.name(), "openrouter");
}
#[test]
fn strip_provider_prefix_returns_model_only() {
assert_eq!(strip_provider_prefix("openai/gpt-4o-mini"), "gpt-4o-mini");
assert_eq!(
strip_provider_prefix("anthropic/claude-sonnet-4-5"),
"claude-sonnet-4-5"
);
assert_eq!(strip_provider_prefix("moonshot/kimi-k2.6"), "kimi-k2.6");
}
#[test]
fn strip_provider_prefix_idempotent_for_bare_names() {
assert_eq!(strip_provider_prefix("gpt-4o-mini"), "gpt-4o-mini");
assert_eq!(strip_provider_prefix(""), "");
}
#[test]
fn supports_vision_for_openai_gpt_4o_slug() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(b.supports(Capability::Vision, "openai/gpt-4o-mini"));
assert!(b.supports(Capability::Vision, "openai/gpt-4o-2024-08-06"));
}
#[test]
fn does_not_support_vision_for_openai_o1_o3_slugs() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(!b.supports(Capability::Vision, "openai/o1-mini"));
assert!(!b.supports(Capability::Vision, "openai/o3"));
assert!(!b.supports(Capability::Vision, "openai/o3-mini"));
}
#[test]
fn supports_vision_for_anthropic_claude_slugs() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(b.supports(Capability::Vision, "anthropic/claude-sonnet-4-5"));
assert!(b.supports(Capability::Vision, "anthropic/claude-haiku-4-5"));
assert!(b.supports(Capability::Vision, "anthropic/claude-3-5-sonnet"));
}
#[test]
fn supports_vision_for_google_gemini_15_20_25_slugs() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(b.supports(Capability::Vision, "google/gemini-1.5-pro"));
assert!(b.supports(Capability::Vision, "google/gemini-2.0-flash"));
assert!(b.supports(Capability::Vision, "google/gemini-2.5-pro"));
assert!(b.supports(Capability::Vision, "google/gemini-2.5-flash"));
}
#[test]
fn does_not_support_vision_for_legacy_gemini_pro() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(!b.supports(Capability::Vision, "google/gemini-pro"));
assert!(!b.supports(Capability::Vision, "google/gemini-1.0-pro"));
}
#[test]
fn supports_vision_for_meta_llama_vision_and_llava() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(b.supports(Capability::Vision, "meta-llama/llama-3.2-vision-11b"));
assert!(b.supports(Capability::Vision, "meta-llama/llava-llama-3"));
}
#[test]
fn does_not_support_vision_for_text_only_meta_llama() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(!b.supports(Capability::Vision, "meta-llama/llama-3.1-70b-instruct"));
assert!(!b.supports(Capability::Vision, "meta-llama/llama-3.3-70b-instruct"));
}
#[test]
fn supports_vision_for_qwen_vl_slugs() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(b.supports(Capability::Vision, "qwen/qwen2-vl-7b-instruct"));
assert!(b.supports(Capability::Vision, "qwen/qwen2.5-vl-72b-instruct"));
}
#[test]
fn supports_vision_for_mistral_pixtral() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(b.supports(Capability::Vision, "mistralai/pixtral-12b-2409"));
}
#[test]
fn does_not_support_vision_for_text_only_mistral() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(!b.supports(Capability::Vision, "mistralai/mistral-large"));
}
#[test]
fn does_not_support_vision_for_bare_model_name() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(!b.supports(Capability::Vision, "gpt-4o-mini"));
}
#[test]
fn does_not_support_vision_for_unknown_provider() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(!b.supports(Capability::Vision, "newprovider/exotic-model-7b"));
}
#[test]
fn supports_lockedparams_for_openai_o1_o3_slugs() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(b.supports(Capability::LockedParams, "openai/o1-mini"));
assert!(b.supports(Capability::LockedParams, "openai/o3"));
assert!(b.supports(Capability::LockedParams, "openai/o3-mini"));
}
#[test]
fn supports_lockedparams_for_moonshot_kimi_k2_slug() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(b.supports(Capability::LockedParams, "moonshot/kimi-k2.6"));
assert!(b.supports(Capability::LockedParams, "moonshot/kimi-k2.8"));
}
#[test]
fn does_not_support_lockedparams_for_chat_slugs() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert!(!b.supports(Capability::LockedParams, "openai/gpt-4o-mini"));
assert!(!b.supports(Capability::LockedParams, "anthropic/claude-sonnet-4-5"));
assert!(!b.supports(Capability::LockedParams, "google/gemini-2.5-pro"));
}
#[test]
fn body_strips_locked_params_for_slug_form_o1() {
let mut req = req_with(vec![Message::user("hi")]);
req.model = "openai/o1-mini".into();
req.temperature = Some(0.7);
let body = build_request_body(&req, "openai/gpt-4o-mini", false);
assert!(body.get("temperature").is_none());
}
#[test]
fn body_strips_locked_params_for_slug_form_kimi_k2() {
let mut req = req_with(vec![Message::user("hi")]);
req.model = "moonshot/kimi-k2.6".into();
req.temperature = Some(0.5);
req.top_p = Some(0.9);
let body = build_request_body(&req, "openai/gpt-4o-mini", false);
assert!(body.get("temperature").is_none());
assert!(body.get("top_p").is_none());
}
#[test]
fn body_keeps_sampling_params_for_unlocked_slug() {
let mut req = req_with(vec![Message::user("hi")]);
req.model = "openai/gpt-4o-mini".into();
req.temperature = Some(0.5);
let body = build_request_body(&req, "openai/gpt-4o-mini", false);
assert_eq!(body["temperature"], 0.5);
}
#[test]
fn count_tokens_uses_o200k_for_openai_gpt_4o_slug() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
let n = b.count_tokens("openai/gpt-4o-mini", "hello world");
assert!(n > 0);
assert!(n <= 5);
}
#[test]
fn count_tokens_uses_cl100k_for_moonshot_slug() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
let n = b.count_tokens("moonshot/kimi-k2.6", "hello world");
assert!(n > 0);
}
#[test]
fn count_tokens_uses_estimate_for_anthropic_slug() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
assert_eq!(b.count_tokens("anthropic/claude-sonnet-4-5", "ABCDEFGH"), 2);
}
#[tokio::test]
async fn stream_delegates_to_base_not_implemented_path() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
match b.stream(ChatRequest::default()).await {
Err(BackendError::Generic { ref message, .. }) => {
assert!(message.contains("streaming not yet implemented"));
}
Err(other) => panic!("expected Generic, got {other:?}"),
Ok(_) => panic!("expected error, got Ok"),
}
}
#[tokio::test]
async fn complete_without_api_key_returns_auth_error() {
let b =
OpenRouterBackend::with_api_key(None).with_base_url("http://127.0.0.1:0");
let err = b
.complete(ChatRequest {
messages: vec![Message::user("hi")],
..Default::default()
})
.await
.unwrap_err();
match err {
BackendError::Auth { api_key_env, .. } => {
assert_eq!(api_key_env.as_deref(), Some(API_KEY_ENV));
}
other => panic!("expected Auth, got {other:?}"),
}
}
#[test]
fn supports_streaming_tooluse_structured_via_base() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
let any = "openai/gpt-4o-mini";
assert!(b.supports(Capability::Streaming, any));
assert!(b.supports(Capability::ToolUse, any));
assert!(b.supports(Capability::StructuredOutput, any));
}
#[test]
fn does_not_support_anthropic_or_gemini_only_caps() {
let b = OpenRouterBackend::with_api_key(Some("k".into()));
let any = "openai/gpt-4o-mini";
assert!(!b.supports(Capability::PromptCaching, any));
assert!(!b.supports(Capability::SafetySettings, any));
}
}