litellm/litellm-rust/crates/inference-responses/tests/live/openrouter.rs
yujonglee c0d7264e8b
feat(rust): add native OpenRouter Responses (#46000)
The Responses route now resolves openrouter alongside openai. The
OpenRouter config reuses the OpenAI request and response transforms and
owns only its bearer credential (OPENROUTER_API_KEY, then OR_API_KEY) and
base URL (OPENROUTER_API_BASE). Wire model ids follow Python's
get_llm_provider, including the openrouter/auto native id with an
explicit provider. The Python route host no longer hands litellm.openai_key
to OpenRouter.

The OpenRouter live test lives in tests/live/openrouter.rs and returns
early when the provider key is not exported, so cargo test stays offline
by default instead of hiding it behind --ignored.

Co-authored-by: nate-berri <nate@berri.ai>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 11:02:03 -07:00

98 lines
3.1 KiB
Rust

use std::{sync::Arc, time::Duration};
use futures_util::TryStreamExt;
use litellm_inference_responses::types::{ResponsesCall, ResponsesOutput};
use litellm_inference_testing::RecordingSecrets;
use litellm_llms::base_llm::responses::streaming::decode;
use litellm_llms_types::formats::responses::streaming::ResponsesEventType;
use rstest::{fixture, rstest};
use serde_json::json;
use crate::support::responses_route;
const DEFAULT_MODEL: &str = "openrouter/openai/gpt-5.5";
/// `OPENROUTER_API_KEY` as a secret source; tests skip when it is unset.
#[fixture]
fn secrets() -> Option<Arc<RecordingSecrets>> {
let key = std::env::var("OPENROUTER_API_KEY").ok()?;
Some(Arc::new(RecordingSecrets::new([(
"OPENROUTER_API_KEY",
key.as_str(),
)])))
}
fn call(stream: bool) -> ResponsesCall {
ResponsesCall {
model: std::env::var("OPENROUTER_LIVE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()),
input: json!("Reply with the single word: pong"),
optional_params: json!({"max_output_tokens": 32, "stream": stream})
.as_object()
.unwrap()
.clone(),
api_key: None,
api_base: None,
custom_llm_provider: None,
extra_headers: None,
timeout: Some(Duration::from_secs(60)),
}
}
#[rstest]
#[tokio::test]
async fn a_complete_response_carries_output_and_usage(secrets: Option<Arc<RecordingSecrets>>) {
let Some(secrets) = secrets else { return };
let output = responses_route(secrets)
.execute(call(false), &(), None)
.await
.unwrap();
let ResponsesOutput::Complete(response) = output else {
panic!("expected a complete response");
};
let body = serde_json::to_value(response).unwrap();
let text = body["output"]
.as_array()
.into_iter()
.flatten()
.filter_map(|item| item["content"].as_array())
.flatten()
.filter_map(|part| part["text"].as_str())
.collect::<String>();
assert!(text.to_ascii_lowercase().contains("pong"), "{body}");
assert!(
body["usage"]["total_tokens"].as_u64().unwrap() > 0,
"{body}"
);
}
#[rstest]
#[tokio::test]
async fn a_stream_ends_with_response_completed(secrets: Option<Arc<RecordingSecrets>>) {
let Some(secrets) = secrets else { return };
let output = responses_route(secrets)
.execute(call(true), &(), None)
.await
.unwrap();
let ResponsesOutput::Stream { chunks, .. } = output else {
panic!("expected SSE");
};
let wire = chunks.try_collect::<Vec<_>>().await.unwrap().concat();
let events = decode(&wire).unwrap();
assert_eq!(
events.first().map(|event| &event.event_type),
Some(&ResponsesEventType::ResponseCreated)
);
assert!(
events
.iter()
.any(|event| event.event_type == ResponsesEventType::OutputTextDelta)
);
let last = events.last().unwrap();
assert_eq!(last.event_type, ResponsesEventType::ResponseCompleted);
assert!(
last.data["response"]["usage"]["total_tokens"]
.as_u64()
.unwrap()
> 0
);
}