Files
openhuman/tests/inference_provider_e2e.rs
T

570 lines
19 KiB
Rust

//! Inference provider end-to-end tests using wiremock.
//!
//! These tests spin up a wiremock HTTP server on a random port and verify
//! that `OpenAiCompatibleProvider` sends correct request bodies and correctly
//! interprets responses for the major provider shapes (OpenAI-compat,
//! Anthropic auth, streaming, temperature suppression, Ollama endpoint).
//!
//! The `/v1/chat/completions` and `/v1/models` HTTP endpoint tests verify the
//! full axum router layer (auth middleware + provider routing) end-to-end.
//!
//! No live LLM API calls are made.
use std::sync::{Mutex, OnceLock};
use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode};
use serde_json::{json, Value};
use tempfile::tempdir;
use tower::ServiceExt;
use wiremock::matchers::{header as wm_header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
use openhuman_core::core::jsonrpc::build_core_http_router;
use openhuman_core::openhuman::inference::provider::compatible::{
AuthStyle, OpenAiCompatibleProvider,
};
use openhuman_core::openhuman::inference::provider::traits::{ChatMessage, Provider};
// ── Environment serialisation lock ───────────────────────────────────────────
//
// Tests that mutate OPENHUMAN_WORKSPACE or OPENHUMAN_CORE_TOKEN must acquire
// this lock first to prevent races when cargo runs tests in parallel threads
// within the same process.
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
static RPC_AUTH_INIT: OnceLock<()> = OnceLock::new();
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
let m = ENV_LOCK.get_or_init(|| Mutex::new(()));
match m.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
}
}
const TEST_RPC_TOKEN: &str = "inference-provider-e2e-token";
fn ensure_rpc_auth() {
RPC_AUTH_INIT.get_or_init(|| {
// SAFETY: test-only, serialised by OnceLock.
unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
let tmp = tempdir().expect("tempdir");
init_rpc_token(tmp.path()).expect("init rpc auth token");
// Keep tmp alive for the process duration by leaking it — the token
// file must remain readable for all subsequent auth checks.
std::mem::forget(tmp);
});
}
// ── Canned OpenAI-compatible response body ────────────────────────────────────
fn openai_chat_response(content: &str) -> Value {
json!({
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1_700_000_000_u64,
"model": "gpt-4o-mini",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15 }
})
}
// ── Helper: build an env-isolated Config pointing at tempdir ─────────────────
/// Sets OPENHUMAN_WORKSPACE to `dir` and returns an `EnvVarGuard` that
/// restores the previous value on drop. Must be called under `env_lock()`.
struct EnvGuard {
key: &'static str,
prev: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, val: &str) -> Self {
let prev = std::env::var(key).ok();
// SAFETY: caller holds env_lock().
unsafe { std::env::set_var(key, val) };
Self { key, prev }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prev {
// SAFETY: caller's env_lock guard is still alive during drop.
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
// ── Test 1: OpenAI-compat chat returns canned text ───────────────────────────
#[tokio::test]
async fn openai_compat_chat_returns_canned_text() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("Hello!")))
.mount(&server)
.await;
let provider = OpenAiCompatibleProvider::new(
"test",
&format!("{}/v1", server.uri()),
Some("test-key"),
AuthStyle::Bearer,
);
let messages = vec![ChatMessage::user("hi")];
let result = provider
.chat_with_history(&messages, "gpt-4o-mini", 0.7)
.await
.expect("chat_with_history should succeed");
assert_eq!(result, "Hello!");
}
// ── Test 2: Temperature present for normal model ──────────────────────────────
#[tokio::test]
async fn openai_compat_temperature_present_for_normal_model() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("ok")))
.mount(&server)
.await;
let provider = OpenAiCompatibleProvider::new(
"test",
&format!("{}/v1", server.uri()),
Some("key"),
AuthStyle::Bearer,
);
provider
.chat_with_history(&[ChatMessage::user("hi")], "gpt-4o-mini", 0.7)
.await
.expect("should succeed");
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
let body: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert!(
body.get("temperature").is_some(),
"temperature should be present for gpt-4o-mini; body={body}"
);
assert_eq!(body["temperature"].as_f64().unwrap(), 0.7);
}
// ── Test 3: Temperature omitted for o1 models ────────────────────────────────
#[tokio::test]
async fn openai_compat_omits_temperature_for_o1_models() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("done")))
.mount(&server)
.await;
let provider = OpenAiCompatibleProvider::new(
"test",
&format!("{}/v1", server.uri()),
Some("key"),
AuthStyle::Bearer,
)
.with_temperature_unsupported_models(vec!["o1*".to_string()]);
provider
.chat_with_history(&[ChatMessage::user("reason")], "o1-preview", 0.7)
.await
.expect("should succeed");
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
let body: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert!(
body.get("temperature").is_none(),
"temperature must be absent for o1-preview; body={body}"
);
// Response should still be returned correctly.
}
// ── Test 4: Temperature omitted for gpt-5 models ─────────────────────────────
#[tokio::test]
async fn openai_compat_omits_temperature_for_gpt5_models() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("done")))
.mount(&server)
.await;
let provider = OpenAiCompatibleProvider::new(
"test",
&format!("{}/v1", server.uri()),
Some("key"),
AuthStyle::Bearer,
)
.with_temperature_unsupported_models(vec![
"o1*".to_string(),
"o3*".to_string(),
"o4*".to_string(),
"gpt-5*".to_string(),
]);
for model in &["gpt-5", "gpt-5-turbo", "o3-mini", "o4-preview"] {
server.reset().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("done")))
.mount(&server)
.await;
provider
.chat_with_history(&[ChatMessage::user("test")], model, 0.7)
.await
.expect("should succeed");
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1, "model={model}");
let body: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert!(
body.get("temperature").is_none(),
"temperature must be absent for model={model}; body={body}"
);
}
}
// ── Test 5: Anthropic auth style ─────────────────────────────────────────────
#[tokio::test]
async fn openai_compat_anthropic_auth_uses_x_api_key_header() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(wm_header("x-api-key", "sk-ant-test"))
.and(wm_header("anthropic-version", "2023-06-01"))
.respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("hi")))
.mount(&server)
.await;
let provider = OpenAiCompatibleProvider::new(
"anthropic",
&format!("{}/v1", server.uri()),
Some("sk-ant-test"),
AuthStyle::Anthropic,
);
let result = provider
.chat_with_history(&[ChatMessage::user("hello")], "claude-3-haiku", 0.5)
.await
.expect("Anthropic auth chat should succeed");
assert_eq!(result, "hi");
// Verify Bearer header was NOT sent.
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
let auth = requests[0].headers.get("authorization");
assert!(
auth.is_none(),
"Authorization header must NOT be set for Anthropic auth; found {:?}",
auth
);
}
// ── Test 6: Streaming response returns ordered deltas ────────────────────────
#[tokio::test]
async fn openai_compat_streaming_returns_ordered_deltas() {
let server = MockServer::start().await;
let sse_body = concat!(
"data: {\"id\":\"x\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"Hel\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"x\",\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"x\",\"choices\":[{\"delta\":{\"content\":\"!\"},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
);
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(sse_body),
)
.mount(&server)
.await;
let provider = OpenAiCompatibleProvider::new(
"test",
&format!("{}/v1", server.uri()),
Some("key"),
AuthStyle::Bearer,
);
// stream_chat_with_system is the implemented streaming method on this provider.
let options = openhuman_core::openhuman::inference::provider::traits::StreamOptions::new(true);
use futures_util::StreamExt;
let mut stream = provider.stream_chat_with_system(
Some("You are helpful."),
"Say Hello!",
"gpt-4o-mini",
0.7,
options,
);
let mut deltas = Vec::new();
while let Some(result) = stream.next().await {
let chunk = result.expect("stream chunk should be Ok");
if !chunk.delta.is_empty() {
deltas.push(chunk.delta);
}
}
let combined = deltas.join("");
assert_eq!(
combined, "Hello!",
"combined stream deltas should equal 'Hello!'; got '{combined}'"
);
}
// ── Test 7: Ollama endpoint shape ────────────────────────────────────────────
#[tokio::test]
async fn ollama_compat_chat_via_openai_v1_endpoint() {
let server = MockServer::start().await;
// Ollama via OpenAI-compat /v1 endpoint — wiremock pretends to be Ollama.
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("Bonjour!")))
.mount(&server)
.await;
// Factory builds Ollama provider via OpenAiCompatibleProvider at /v1.
let base = server.uri();
let endpoint = format!("{}/v1", base.trim_end_matches('/'));
let provider = OpenAiCompatibleProvider::new("ollama", &endpoint, None, AuthStyle::None);
let result = provider
.chat_with_history(&[ChatMessage::user("Bonjour?")], "llama3", 0.7)
.await
.expect("Ollama compat chat should succeed");
assert_eq!(result, "Bonjour!");
}
// ── Test 8: /v1/chat/completions HTTP endpoint — unauthorized ─────────────────
#[tokio::test]
async fn http_endpoint_chat_completions_no_bearer_returns_401() {
let _lock = env_lock();
ensure_rpc_auth();
let body = json!({
"model": "ollama:llama3",
"messages": [{ "role": "user", "content": "hello" }]
});
let req = Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap();
let resp = build_core_http_router(false).oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ── Test 9: /v1/models — unauthorized ────────────────────────────────────────
#[tokio::test]
async fn http_endpoint_models_no_bearer_returns_401() {
let _lock = env_lock();
ensure_rpc_auth();
let req = Request::builder()
.method(Method::GET)
.uri("/v1/models")
.body(Body::empty())
.unwrap();
let resp = build_core_http_router(false).oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ── Test 10: /v1/models with bearer returns non-empty list ───────────────────
#[tokio::test]
async fn http_endpoint_models_with_bearer_returns_model_list() {
let _lock = env_lock();
ensure_rpc_auth();
let tmp = tempdir().expect("tempdir");
let _workspace_guard = EnvGuard::set("OPENHUMAN_WORKSPACE", tmp.path().to_str().unwrap());
let req = Request::builder()
.method(Method::GET)
.uri("/v1/models")
.header(header::AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
.body(Body::empty())
.unwrap();
let resp = build_core_http_router(false).oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::UNAUTHORIZED,
"401 must not fire when bearer is present"
);
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"403 must not fire when bearer is present"
);
if resp.status().is_success() {
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
let models = json.get("data").and_then(Value::as_array);
if let Some(list) = models {
assert!(
!list.is_empty(),
"/v1/models should return at least one model"
);
}
}
}
// ── Test 11: /v1/chat/completions with bearer passes auth ────────────────────
#[tokio::test]
async fn http_endpoint_chat_completions_with_bearer_passes_auth() {
let _lock = env_lock();
ensure_rpc_auth();
let body = json!({
"model": "ollama:llama3",
"messages": [{ "role": "user", "content": "ping" }],
"stream": false
});
let req = Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap();
let resp = build_core_http_router(false).oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::UNAUTHORIZED,
"401 must not fire when bearer is present"
);
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"403 must not fire when bearer is present"
);
}
// ── Test 12: Request model field is preserved ─────────────────────────────────
#[tokio::test]
async fn openai_compat_request_body_contains_correct_model() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("ok")))
.mount(&server)
.await;
let provider = OpenAiCompatibleProvider::new(
"test",
&format!("{}/v1", server.uri()),
Some("key"),
AuthStyle::Bearer,
);
provider
.chat_with_history(&[ChatMessage::user("hi")], "claude-3-sonnet", 0.5)
.await
.expect("should succeed");
let requests = server.received_requests().await.unwrap();
let body: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(body["model"].as_str().unwrap(), "claude-3-sonnet");
}
// ── Test 13: Bearer token is sent in Authorization header ────────────────────
#[tokio::test]
async fn openai_compat_bearer_auth_sends_authorization_header() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(wm_header("authorization", "Bearer secret-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("ok")))
.mount(&server)
.await;
let provider = OpenAiCompatibleProvider::new(
"test",
&format!("{}/v1", server.uri()),
Some("secret-key"),
AuthStyle::Bearer,
);
let result = provider
.chat_with_history(&[ChatMessage::user("hi")], "gpt-4o", 0.7)
.await
.expect("should succeed");
assert_eq!(result, "ok");
}
// ── Test 14: temperature_for_model helper ────────────────────────────────────
#[test]
fn temperature_helper_suppresses_o1_by_default_config() {
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::inference::provider::temperature::temperature_for_model;
let config = Config::default();
// Normal model → temperature returned
assert_eq!(
temperature_for_model("gpt-4o-mini", 0.7, &config),
Some(0.7)
);
assert_eq!(
temperature_for_model("claude-3-sonnet", 0.5, &config),
Some(0.5)
);
// o1/o3/o4/gpt-5 → temperature suppressed
assert_eq!(temperature_for_model("o1-preview", 0.7, &config), None);
assert_eq!(temperature_for_model("o3-mini", 0.7, &config), None);
assert_eq!(temperature_for_model("o4-turbo", 0.7, &config), None);
assert_eq!(temperature_for_model("gpt-5-turbo", 0.7, &config), None);
}