Files
openhuman/tests/inference_provider_raw_coverage_e2e.rs
T

701 lines
23 KiB
Rust

//! Round 15 raw/E2E coverage for inference provider and local runtime paths.
//!
//! These tests use only loopback HTTP mocks and temp workspaces. They do not
//! require real Ollama, LM Studio, Piper, Whisper, Python, or model binaries.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use axum::body::Body;
use axum::extract::State;
use axum::http::{header, HeaderMap, Response, StatusCode};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use tempfile::{tempdir, TempDir};
use openhuman_core::openhuman::config::schema::cloud_providers::{
AuthStyle as CloudAuthStyle, CloudProviderCreds,
};
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::credentials::{
AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME,
};
use openhuman_core::openhuman::inference::local::LocalAiService;
use openhuman_core::openhuman::inference::provider::compatible::{
AuthStyle as CompatibleAuthStyle, OpenAiCompatibleProvider,
};
use openhuman_core::openhuman::inference::provider::factory::{
auth_key_for_slug, create_chat_provider_from_string, provider_for_role,
};
use openhuman_core::openhuman::inference::provider::{
list_configured_models, sanitize_api_error, ChatMessage, ChatRequest, Provider, ProviderDelta,
};
use openhuman_core::openhuman::tools::ToolSpec;
#[derive(Clone, Default)]
struct MockState {
requests: Arc<Mutex<Vec<(String, Option<String>, Value)>>>,
}
struct EnvVarGuard {
key: &'static str,
previous: Option<String>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let previous = std::env::var(key).ok();
// SAFETY: this test binary is run with --test-threads=1 in validation.
unsafe { std::env::set_var(key, value) };
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => {
// SAFETY: this test binary is run with --test-threads=1 in validation.
unsafe { std::env::set_var(self.key, value) }
}
None => {
// SAFETY: this test binary is run with --test-threads=1 in validation.
unsafe { std::env::remove_var(self.key) }
}
}
}
}
#[tokio::test]
async fn compatible_provider_covers_chat_responses_streaming_tools_and_errors() {
let (base, state) = serve_mock().await;
let provider = OpenAiCompatibleProvider::new_with_user_agent(
"custom_openai",
&format!("{base}/v1"),
Some("sk-test-secret"),
CompatibleAuthStyle::Bearer,
"round15-agent",
)
.with_temperature_unsupported_models(vec!["cold-*".to_string()])
.with_temperature_override(Some(0.7));
let simple = provider
.chat_with_system(Some("system"), "hello", "demo-chat", 0.2)
.await
.expect("chat_with_system");
assert_eq!(simple, "chat:demo-chat");
let history = provider
.chat_with_history(
&[ChatMessage::system("rules"), ChatMessage::user("history")],
"responses-only",
0.3,
)
.await
.expect("responses fallback");
assert_eq!(history, "responses fallback text");
let tools = vec![ToolSpec {
name: "lookup".to_string(),
description: "lookup a thing".to_string(),
parameters: json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
}),
}];
let native = provider
.chat(
ChatRequest {
messages: &[ChatMessage::user("use a tool")],
tools: Some(&tools),
stream: None,
max_tokens: None,
},
"tool-model",
0.1,
)
.await
.expect("native tools");
assert_eq!(native.text.as_deref(), Some("tool response"));
assert_eq!(native.tool_calls.len(), 1);
assert_eq!(native.tool_calls[0].name, "lookup");
assert_eq!(native.tool_calls[0].arguments, r#"{"query":"openhuman"}"#);
let usage = native.usage.expect("usage");
assert_eq!(usage.input_tokens, 11);
assert_eq!(usage.output_tokens, 7);
assert_eq!(usage.cached_input_tokens, 3);
assert!((usage.charged_amount_usd - 0.0042).abs() < f64::EPSILON);
let (tx, mut rx) = tokio::sync::mpsc::channel::<ProviderDelta>(16);
let streamed = provider
.chat(
ChatRequest {
messages: &[ChatMessage::user("stream it")],
tools: Some(&tools),
stream: Some(&tx),
max_tokens: None,
},
"stream-model",
0.1,
)
.await
.expect("streaming native chat");
drop(tx);
assert_eq!(streamed.text.as_deref(), Some("hello world"));
assert_eq!(streamed.reasoning_content.as_deref(), Some("thinking"));
assert_eq!(streamed.tool_calls.len(), 1);
let deltas = collect_deltas(&mut rx).await;
assert!(deltas
.iter()
.any(|d| matches!(d, ProviderDelta::TextDelta { delta } if delta == "hello ")));
assert!(deltas
.iter()
.any(|d| matches!(d, ProviderDelta::ThinkingDelta { delta } if delta == "thinking")));
assert!(deltas.iter().any(
|d| matches!(d, ProviderDelta::ToolCallStart { tool_name, .. } if tool_name == "lookup")
));
let err = provider
.chat_with_system(None, "boom", "budget-model", 0.2)
.await
.expect_err("budget error");
assert!(err.to_string().contains("budget exhausted"));
assert_eq!(
sanitize_api_error("leaked sk-abcdef ghp_secret-token"),
"leaked [REDACTED] [REDACTED]"
);
let cold = provider
.chat_with_system(None, "no temperature", "cold-no-temp", 0.2)
.await
.expect("temperature omitted");
assert_eq!(cold, "chat:cold-no-temp");
let seen = state.requests.lock().expect("requests");
assert!(seen.iter().any(|(path, auth, _)| {
path == "/v1/chat/completions" && auth.as_deref() == Some("Bearer sk-test-secret")
}));
assert!(seen
.iter()
.any(|(path, _, body)| path == "/v1/responses" && body["instructions"] == "rules"));
assert!(seen.iter().any(|(_, _, body)| {
body.get("temperature").is_none() && body["model"] == "cold-no-temp"
}));
}
#[tokio::test]
async fn provider_factory_and_model_listing_cover_cloud_local_and_invalid_shapes() {
let (base, _state) = serve_mock().await;
let tmp = tempdir().expect("tempdir");
let mut config = temp_config(&tmp);
let _workspace_env = EnvVarGuard::set(
"OPENHUMAN_WORKSPACE",
config.config_path.parent().expect("config parent"),
);
seed_session(&config);
config.cloud_providers = vec![
CloudProviderCreds {
id: "custom-id".to_string(),
slug: "custom".to_string(),
label: "Custom".to_string(),
endpoint: format!("{base}/v1"),
auth_style: CloudAuthStyle::Bearer,
legacy_type: None,
default_model: Some("demo-chat".to_string()),
},
CloudProviderCreds {
id: "openrouter-id".to_string(),
slug: "openrouter".to_string(),
label: "OpenRouter".to_string(),
endpoint: format!("{base}/openrouter"),
auth_style: CloudAuthStyle::Bearer,
legacy_type: None,
default_model: None,
},
CloudProviderCreds {
id: "missing-id".to_string(),
slug: "missing".to_string(),
label: "Missing".to_string(),
endpoint: format!("{base}/missing"),
auth_style: CloudAuthStyle::None,
legacy_type: None,
default_model: None,
},
CloudProviderCreds {
id: "html-id".to_string(),
slug: "html".to_string(),
label: "HTML".to_string(),
endpoint: format!("{base}/html"),
auth_style: CloudAuthStyle::None,
legacy_type: None,
default_model: None,
},
CloudProviderCreds {
id: "wrong-data-id".to_string(),
slug: "wrong-data".to_string(),
label: "Wrong Data".to_string(),
endpoint: format!("{base}/wrong-data"),
auth_style: CloudAuthStyle::None,
legacy_type: None,
default_model: None,
},
CloudProviderCreds {
id: "error-payload-id".to_string(),
slug: "error-payload".to_string(),
label: "Error Payload".to_string(),
endpoint: format!("{base}/error-payload"),
auth_style: CloudAuthStyle::None,
legacy_type: None,
default_model: None,
},
];
config.chat_provider = Some("custom:demo-chat@0.4".to_string());
config.reasoning_provider = None;
config.local_ai.base_url = Some(base.clone());
AuthService::from_config(&config)
.store_provider_token(
&auth_key_for_slug("custom"),
DEFAULT_AUTH_PROFILE_NAME,
"custom-key",
HashMap::new(),
true,
)
.expect("store custom key");
AuthService::from_config(&config)
.store_provider_token(
&auth_key_for_slug("openrouter"),
DEFAULT_AUTH_PROFILE_NAME,
"openrouter-key",
HashMap::new(),
true,
)
.expect("store openrouter key");
config.save().await.expect("save temp config");
assert_eq!(provider_for_role("chat", &config), "custom:demo-chat@0.4");
assert_eq!(
provider_for_role("reasoning", &config),
"custom:demo-chat@0.4"
);
let (_provider, model) =
create_chat_provider_from_string("chat", "custom:demo-chat@0.4", &config)
.expect("cloud provider");
assert_eq!(model, "demo-chat");
let (_local_provider, local_model) =
create_chat_provider_from_string("chat", "ollama:gemma3:1b-it-qat@0.1", &config)
.expect("ollama provider");
assert_eq!(local_model, "gemma3:1b-it-qat");
let empty_model = match create_chat_provider_from_string("chat", "ollama:", &config) {
Ok(_) => panic!("expected empty model error"),
Err(err) => err,
};
assert!(empty_model.to_string().contains("empty model"));
let listed = list_configured_models("custom")
.await
.expect("list models")
.value;
assert_eq!(listed["models"][0]["id"], "demo-chat");
assert_eq!(listed["models"][1]["context_window"], 8192);
let local_listed = list_configured_models("ollama")
.await
.expect("synthetic ollama list")
.value;
assert_eq!(local_listed["models"][0]["id"], "demo-chat");
// PR #2959 reverted the list_models 404 suppression: a 404 from /models
// now surfaces as a real error instead of a synthetic `unsupported: true`
// success, so the failure fires to Sentry for a root-cause fix.
let missing_err = list_configured_models("missing")
.await
.expect_err("404 list_models now surfaces as an error");
assert!(
missing_err.contains("provider returned 404"),
"404 list_models error should surface the status: {missing_err:?}"
);
let openrouter = list_configured_models("openrouter")
.await
.expect("openrouter key validation and list")
.value;
assert_eq!(openrouter["models"][0]["owned_by"], "test-suite");
for provider_id in ["html", "wrong-data", "error-payload", ""] {
let err = list_configured_models(provider_id)
.await
.expect_err("invalid model listing");
assert!(
err.contains("provider")
|| err.contains("provider_id")
|| err.contains("OpenRouter")
|| err.contains("parse JSON")
);
}
}
#[tokio::test]
async fn local_service_public_inference_assets_and_shutdown_use_loopback_ollama() {
let (base, _state) = serve_mock().await;
let _ollama_env = EnvVarGuard::set("OPENHUMAN_OLLAMA_BASE_URL", &base);
let tmp = tempdir().expect("tempdir");
let mut config = temp_config(&tmp);
config.local_ai.runtime_enabled = true;
config.local_ai.base_url = Some(base);
config.local_ai.chat_model_id = "gemma3:1b-it-qat".to_string();
config.local_ai.vision_model_id = "llava:mock".to_string();
config.local_ai.embedding_model_id = "bge-m3".to_string();
config.local_ai.preload_embedding_model = true;
config.local_ai.preload_vision_model = false;
config.local_ai.preload_stt_model = false;
config.local_ai.preload_tts_voice = false;
config.local_ai.whisper_in_process = false;
let service = LocalAiService::new(&config);
let prompt = service
.prompt(&config, "Say hi", Some(8), true)
.await
.expect("prompt");
assert_eq!(prompt, "generated final");
let summarized = service
.summarize(&config, "one two three", Some(16))
.await
.expect("summarize");
assert_eq!(summarized, "generated final");
let completion = service
.inline_complete_interactive(
&config,
"OpenHuman is",
"concise",
Some("short"),
&["OpenHuman is useful".to_string()],
Some(6),
)
.await
.expect("inline");
assert_eq!(completion, "generated final");
let assets = service.assets_status(&config).await.expect("assets");
assert!(assets.ollama_available);
assert_eq!(assets.chat.state, "ready");
assert_eq!(assets.embedding.state, "ready");
assert!(matches!(assets.stt.state.as_str(), "ondemand" | "missing"));
assert!(matches!(
assets.tts.state.as_str(),
"ready" | "ondemand" | "missing"
));
let diagnostics = service.diagnostics(&config).await.expect("diagnostics");
assert_eq!(diagnostics["ollama_running"], true);
assert_eq!(diagnostics["expected"]["chat_found"], true);
assert!(
diagnostics["installed_models"]
.as_array()
.expect("installed_models")
.len()
>= 4
);
let progress = service.downloads_progress(&config).await.expect("progress");
assert_eq!(progress.chat.id, "gemma3:1b-it-qat");
assert_eq!(progress.embedding.id, "bge-m3");
let disabled_config = Config::default();
let disabled_err = service
.prompt(&disabled_config, "disabled", None, false)
.await
.expect_err("disabled prompt");
assert_eq!(disabled_err, "local ai is disabled");
service.shutdown_owned_ollama(&config).await;
assert!(!service.has_owned_ollama());
}
async fn collect_deltas(rx: &mut tokio::sync::mpsc::Receiver<ProviderDelta>) -> Vec<ProviderDelta> {
let mut out = Vec::new();
while let Some(delta) = rx.recv().await {
out.push(delta);
}
out
}
fn temp_config(tmp: &TempDir) -> Config {
let root = tmp.path().join(".openhuman");
std::fs::create_dir_all(root.join("workspace")).expect("workspace dir");
let mut config = Config::default();
config.config_path = root.join("config.toml");
config.workspace_dir = root.join("workspace");
config.secrets.encrypt = false;
config.api_url = Some("http://127.0.0.1:9".to_string());
config
}
fn seed_session(config: &Config) {
AuthService::from_config(config)
.store_provider_token(
APP_SESSION_PROVIDER,
DEFAULT_AUTH_PROFILE_NAME,
"session-token",
HashMap::new(),
true,
)
.expect("seed session");
}
async fn serve_mock() -> (String, MockState) {
let state = MockState::default();
let app = Router::new()
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/responses", post(responses))
.route("/v1/models", get(models))
.route("/missing/models", get(missing_models))
.route("/html/models", get(html_models))
.route("/wrong-data/models", get(wrong_data_models))
.route("/error-payload/models", get(error_payload_models))
.route("/openrouter/key", get(openrouter_key))
.route("/openrouter/models", get(models))
.route("/api/tags", get(ollama_tags))
.route("/api/show", post(ollama_show))
.route("/api/generate", post(ollama_generate))
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind mock");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve mock");
});
(format!("http://{addr}"), state)
}
async fn chat_completions(
State(state): State<MockState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> impl IntoResponse {
remember(&state, "/v1/chat/completions", &headers, body.clone());
let model = body["model"].as_str().unwrap_or_default();
if model == "responses-only" {
return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
}
if model == "budget-model" {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": {"message": "budget exhausted for sk-very-secret"}})),
)
.into_response();
}
if model == "stream-model" && body["stream"] == true {
return sse_response();
}
if model == "tool-model" {
return Json(json!({
"choices": [{
"message": {
"content": "tool response",
"reasoning_content": "hidden reasoning",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "lookup",
"arguments": { "query": "openhuman" }
}
}]
}
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 2,
"prompt_tokens_details": { "cached_tokens": 1 }
},
"openhuman": {
"usage": {
"input_tokens": 11,
"output_tokens": 7,
"cached_input_tokens": 3
},
"billing": { "charged_amount_usd": 0.0042 }
}
}))
.into_response();
}
Json(json!({
"choices": [{
"message": {
"content": format!("chat:{model}"),
"function_call": {
"name": "legacy_tool",
"arguments": "{\"ok\":true}"
}
}
}]
}))
.into_response()
}
async fn responses(
State(state): State<MockState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> impl IntoResponse {
remember(&state, "/v1/responses", &headers, body);
Json(json!({
"output": [{
"content": [{ "type": "output_text", "text": "nested fallback text" }]
}],
"output_text": "responses fallback text"
}))
}
fn sse_response() -> Response<Body> {
let chunks = [
json!({"choices":[{"delta":{"content":"hello "}}]}).to_string(),
json!({"choices":[{"delta":{"reasoning_content":"thinking"}}]}).to_string(),
json!({"choices":[{"delta":{"content":"world","tool_calls":[{
"index":0,
"id":"call_stream",
"type":"function",
"function":{"name":"lookup","arguments":"{\"query\""}
}]}}]})
.to_string(),
json!({"choices":[{"delta":{"tool_calls":[{
"index":0,
"function":{"arguments":":\"stream\"}"}
}]}}]})
.to_string(),
json!({
"choices": [],
"usage": {
"prompt_tokens": 5,
"completion_tokens": 6,
"prompt_tokens_details": { "cached_tokens": 2 }
}
})
.to_string(),
];
let body = chunks
.into_iter()
.map(|chunk| format!("data: {chunk}\n\n"))
.chain(std::iter::once("data: [DONE]\n\n".to_string()))
.collect::<String>();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream")
.body(Body::from(body))
.expect("sse response")
}
async fn models(State(state): State<MockState>, headers: HeaderMap) -> impl IntoResponse {
remember(&state, "/v1/models", &headers, Value::Null);
Json(json!({
"object": "list",
"data": [
{ "id": "demo-chat", "owned_by": "test-suite" },
{ "id": "demo-coder", "owned_by": "test-suite", "context_window": 8192 },
{ "owned_by": "ignored-without-id" }
]
}))
}
async fn missing_models() -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
Json(json!({"error": "models unsupported"})),
)
}
async fn html_models() -> impl IntoResponse {
(StatusCode::OK, "<html>login</html>")
}
async fn wrong_data_models() -> impl IntoResponse {
Json(json!({ "object": "error", "data": { "message": "wrong shape" } }))
}
async fn error_payload_models() -> impl IntoResponse {
Json(json!({ "error": { "message": "bad sk-secret-key" } }))
}
async fn openrouter_key(headers: HeaderMap) -> impl IntoResponse {
let auth = header_value(&headers, "authorization").unwrap_or_default();
if auth == "Bearer openrouter-key" {
Json(json!({"data": {"label": "ok"}})).into_response()
} else {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "missing key"})),
)
.into_response()
}
}
async fn ollama_tags() -> impl IntoResponse {
Json(json!({
"models": [
{ "name": "gemma3:1b-it-qat", "model": "gemma3:1b-it-qat" },
{ "name": "llava:mock", "model": "llava:mock" },
{ "name": "bge-m3", "model": "bge-m3" },
{ "name": "demo-chat", "model": "demo-chat" }
]
}))
}
async fn ollama_show(Json(body): Json<Value>) -> impl IntoResponse {
let model = body
.get("model")
.or_else(|| body.get("name"))
.and_then(Value::as_str)
.unwrap_or_default();
if model == "___nonexistent_probe___" {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "model not found"})),
)
.into_response();
}
Json(json!({
"model_info": {
"general.context_length": if model == "bge-m3" { 8192 } else { 4096 }
}
}))
.into_response()
}
async fn ollama_generate(Json(_body): Json<Value>) -> impl IntoResponse {
Json(json!({
"model": "gemma3:1b-it-qat",
"response": "generated final",
"done": true,
"prompt_eval_count": 8,
"prompt_eval_duration": 400000000,
"eval_count": 6,
"eval_duration": 300000000
}))
}
fn remember(state: &MockState, path: &str, headers: &HeaderMap, body: Value) {
state.requests.lock().expect("requests").push((
path.to_string(),
header_value(headers, "authorization"),
body,
));
}
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
headers
.get(key)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
}