mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
384 lines
12 KiB
Rust
384 lines
12 KiB
Rust
//! Round 25 raw/E2E coverage for inference compatible/admin cold paths.
|
|
//!
|
|
//! This suite uses loopback HTTP mocks and temp workspaces only. It must not
|
|
//! call host Ollama, MLX, Python, whisper, piper, local AI binaries, models, or
|
|
//! downloads.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
|
|
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 futures_util::StreamExt;
|
|
use openhuman_core::core::all::RegisteredController;
|
|
use openhuman_core::openhuman::config::schema::cloud_providers::{
|
|
AuthStyle as CloudAuthStyle, CloudProviderCreds,
|
|
};
|
|
use openhuman_core::openhuman::config::Config;
|
|
use openhuman_core::openhuman::credentials::{AuthService, DEFAULT_AUTH_PROFILE_NAME};
|
|
use openhuman_core::openhuman::inference::local::all_local_inference_registered_controllers;
|
|
use openhuman_core::openhuman::inference::ops::inference_test_provider_model;
|
|
use openhuman_core::openhuman::inference::provider::compatible::{
|
|
AuthStyle as CompatibleAuthStyle, OpenAiCompatibleProvider,
|
|
};
|
|
use openhuman_core::openhuman::inference::provider::factory::auth_key_for_slug;
|
|
use openhuman_core::openhuman::inference::provider::traits::{StreamError, StreamOptions};
|
|
use openhuman_core::openhuman::inference::provider::{
|
|
list_configured_models, ChatMessage, Provider,
|
|
};
|
|
use serde_json::{json, Value};
|
|
use tempfile::{tempdir, TempDir};
|
|
|
|
#[derive(Clone, Default)]
|
|
struct MockState {
|
|
requests: Arc<Mutex<Vec<SeenRequest>>>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct SeenRequest {
|
|
path: String,
|
|
auth: Option<String>,
|
|
body: Value,
|
|
}
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
previous: Option<std::ffi::OsString>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
|
let previous = std::env::var_os(key);
|
|
// SAFETY: validation runs this integration test with --test-threads=1.
|
|
unsafe { std::env::set_var(key, value) };
|
|
Self { key, previous }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
match &self.previous {
|
|
Some(value) => {
|
|
// SAFETY: validation runs this integration test with --test-threads=1.
|
|
unsafe { std::env::set_var(self.key, value) }
|
|
}
|
|
None => {
|
|
// SAFETY: validation runs this integration test with --test-threads=1.
|
|
unsafe { std::env::remove_var(self.key) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
ENV_LOCK
|
|
.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compatible_provider_cold_paths_cover_auth_url_temperature_and_stream_errors() {
|
|
let (base, state) = serve_mock().await;
|
|
|
|
let missing_key = OpenAiCompatibleProvider::new(
|
|
"round25-missing-key",
|
|
&format!("{base}/v1"),
|
|
None,
|
|
CompatibleAuthStyle::Bearer,
|
|
);
|
|
let err = missing_key
|
|
.chat_with_system(None, "must fail before network", "missing-key", 0.1)
|
|
.await
|
|
.expect_err("credential guard");
|
|
assert!(err.to_string().contains("API key not set"));
|
|
let stream_errs = missing_key
|
|
.stream_chat_with_history(
|
|
&[ChatMessage::user("no key stream")],
|
|
"missing-key",
|
|
0.1,
|
|
StreamOptions::new(true),
|
|
)
|
|
.collect::<Vec<_>>()
|
|
.await;
|
|
assert!(matches!(
|
|
&stream_errs[0],
|
|
Err(StreamError::Provider(message)) if message.contains("API key not set")
|
|
));
|
|
|
|
let full_endpoint = OpenAiCompatibleProvider::new(
|
|
"round25-full-endpoint",
|
|
&format!("{base}/direct/chat/completions"),
|
|
Some("sk-full"),
|
|
CompatibleAuthStyle::Bearer,
|
|
)
|
|
.with_temperature_override(Some(0.12))
|
|
.with_temperature_unsupported_models(vec!["cold-*".to_string()]);
|
|
assert_eq!(
|
|
full_endpoint
|
|
.chat_with_system(Some("policy"), "hello", "cold-model", 0.99)
|
|
.await
|
|
.expect("full endpoint chat"),
|
|
"full endpoint ok"
|
|
);
|
|
|
|
let tools_empty = full_endpoint
|
|
.chat_with_tools(&[ChatMessage::user("tools empty")], &[], "hot-model", 0.77)
|
|
.await
|
|
.expect("chat_with_tools empty tools");
|
|
assert_eq!(tools_empty.text.as_deref(), Some("tools empty ok"));
|
|
|
|
let chunks = full_endpoint
|
|
.stream_chat_with_system(
|
|
None,
|
|
"stream denied",
|
|
"stream-policy-denied",
|
|
0.2,
|
|
StreamOptions::new(true),
|
|
)
|
|
.collect::<Vec<_>>()
|
|
.await;
|
|
assert!(matches!(
|
|
&chunks[0],
|
|
Err(StreamError::Provider(message))
|
|
if message.contains("403") && !message.contains("sk-stream-secret")
|
|
));
|
|
|
|
let seen = state.requests.lock().expect("requests");
|
|
let cold = seen
|
|
.iter()
|
|
.find(|req| req.body["model"] == "cold-model")
|
|
.expect("cold request");
|
|
assert_eq!(cold.path, "/direct/chat/completions");
|
|
assert_eq!(cold.auth.as_deref(), Some("Bearer sk-full"));
|
|
assert!(cold.body.get("temperature").is_none());
|
|
|
|
let hot = seen
|
|
.iter()
|
|
.find(|req| req.body["model"] == "hot-model")
|
|
.expect("hot request");
|
|
assert_eq!(hot.body["temperature"], 0.12);
|
|
assert!(hot.body.get("tools").is_none());
|
|
assert!(hot.body.get("tool_choice").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_admin_cold_paths_cover_model_errors_local_factory_and_connection_controller() {
|
|
let _lock = env_lock();
|
|
let (base, _state) = serve_mock().await;
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut config = temp_config(&tmp);
|
|
config.local_ai.base_url = Some(base.clone());
|
|
config.cloud_providers = vec![
|
|
provider_entry(
|
|
"array-id",
|
|
"array-body",
|
|
&format!("{base}/array-body"),
|
|
CloudAuthStyle::None,
|
|
None,
|
|
),
|
|
provider_entry(
|
|
"status-id",
|
|
"status-secret",
|
|
&format!("{base}/status-secret"),
|
|
CloudAuthStyle::Bearer,
|
|
None,
|
|
),
|
|
];
|
|
config.save().await.expect("save config");
|
|
let auth = AuthService::from_config(&config);
|
|
auth.store_provider_token(
|
|
&auth_key_for_slug("status-secret"),
|
|
DEFAULT_AUTH_PROFILE_NAME,
|
|
"sk-status-secret",
|
|
HashMap::new(),
|
|
true,
|
|
)
|
|
.expect("store provider key");
|
|
|
|
let _workspace = EnvVarGuard::set("OPENHUMAN_WORKSPACE", config.config_path.parent().unwrap());
|
|
|
|
let array_err = list_configured_models("array-body")
|
|
.await
|
|
.expect_err("top-level array body");
|
|
assert!(array_err.contains("not a JSON object"));
|
|
assert!(array_err.contains("array"));
|
|
|
|
let status_err = list_configured_models("status-secret")
|
|
.await
|
|
.expect_err("non-2xx provider response");
|
|
assert!(status_err.contains("provider returned 500"));
|
|
assert!(!status_err.contains("sk-status-secret"));
|
|
|
|
let empty_lmstudio = inference_test_provider_model(
|
|
&config,
|
|
"chat",
|
|
"lmstudio: ",
|
|
"should fail before network",
|
|
)
|
|
.await
|
|
.expect_err("empty lmstudio model");
|
|
assert!(empty_lmstudio.contains("empty model"));
|
|
|
|
let controllers = all_local_inference_registered_controllers();
|
|
let test_connection = controller(&controllers, "test_connection");
|
|
let reachable = call(test_connection, json!({"url": base}))
|
|
.await
|
|
.expect("reachable connection");
|
|
assert_eq!(reachable["reachable"], true);
|
|
assert_eq!(reachable["models_count"], 1);
|
|
|
|
let bad_json_base = serve_bad_ollama_json_mock().await;
|
|
let bad_json = call(test_connection, json!({"url": bad_json_base}))
|
|
.await
|
|
.expect("bad json still returns structured unreachable-ish result");
|
|
assert_eq!(bad_json["reachable"], true);
|
|
assert_eq!(bad_json["models_count"], 0);
|
|
|
|
let invalid = call(test_connection, json!({"url": "not-a-url"}))
|
|
.await
|
|
.expect_err("invalid url rejected");
|
|
assert!(invalid.contains("URL must start with http:// or https://"));
|
|
}
|
|
|
|
async fn serve_mock() -> (String, MockState) {
|
|
let state = MockState::default();
|
|
let app = Router::new()
|
|
.route("/direct/chat/completions", post(direct_chat))
|
|
.route("/array-body/models", get(array_models))
|
|
.route("/status-secret/models", get(status_secret_models))
|
|
.route("/api/tags", get(ollama_tags))
|
|
.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 serve_bad_ollama_json_mock() -> String {
|
|
let app = Router::new().route("/api/tags", get(bad_ollama_json));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind bad json mock");
|
|
let addr = listener.local_addr().expect("addr");
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app)
|
|
.await
|
|
.expect("serve bad json mock");
|
|
});
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
async fn direct_chat(
|
|
State(state): State<MockState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<Value>,
|
|
) -> impl IntoResponse {
|
|
remember(&state, "/direct/chat/completions", &headers, body.clone());
|
|
match body["model"].as_str().unwrap_or_default() {
|
|
"stream-policy-denied" => (
|
|
StatusCode::FORBIDDEN,
|
|
"provider denied access for sk-stream-secret",
|
|
)
|
|
.into_response(),
|
|
"hot-model" => {
|
|
Json(json!({"choices":[{"message":{"content":"tools empty ok"}}]})).into_response()
|
|
}
|
|
_ => Json(json!({"choices":[{"message":{"content":"full endpoint ok"}}]})).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn array_models() -> impl IntoResponse {
|
|
Json(json!([{"id":"not-envelope"}]))
|
|
}
|
|
|
|
async fn status_secret_models() -> impl IntoResponse {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"upstream exploded with sk-status-secret",
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn ollama_tags() -> impl IntoResponse {
|
|
Json(json!({"models":[{"name":"round25-model","model":"round25-model","size":1}]}))
|
|
}
|
|
|
|
async fn bad_ollama_json() -> impl IntoResponse {
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from("<html>not ollama json</html>"))
|
|
.expect("bad json response")
|
|
}
|
|
|
|
fn remember(state: &MockState, path: &str, headers: &HeaderMap, body: Value) {
|
|
state.requests.lock().expect("requests").push(SeenRequest {
|
|
path: path.to_string(),
|
|
auth: auth_header(headers),
|
|
body,
|
|
});
|
|
}
|
|
|
|
fn auth_header(headers: &HeaderMap) -> Option<String> {
|
|
headers
|
|
.get(header::AUTHORIZATION)
|
|
.or_else(|| headers.get("x-api-key"))
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn provider_entry(
|
|
id: &str,
|
|
slug: &str,
|
|
endpoint: &str,
|
|
auth_style: CloudAuthStyle,
|
|
default_model: Option<&str>,
|
|
) -> CloudProviderCreds {
|
|
CloudProviderCreds {
|
|
id: id.to_string(),
|
|
slug: slug.to_string(),
|
|
label: slug.to_string(),
|
|
endpoint: endpoint.to_string(),
|
|
auth_style,
|
|
legacy_type: None,
|
|
default_model: default_model.map(ToString::to_string),
|
|
}
|
|
}
|
|
|
|
fn controller<'a>(
|
|
controllers: &'a [RegisteredController],
|
|
function: &str,
|
|
) -> &'a RegisteredController {
|
|
controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == function)
|
|
.unwrap_or_else(|| panic!("controller {function} registered"))
|
|
}
|
|
|
|
async fn call(controller: &RegisteredController, params: Value) -> Result<Value, String> {
|
|
let params = params.as_object().cloned().unwrap_or_default();
|
|
(controller.handler)(params).await
|
|
}
|
|
|
|
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
|
|
}
|