mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
561 lines
18 KiB
Rust
561 lines
18 KiB
Rust
//! Round 21 raw/E2E coverage for local inference service paths.
|
|
//!
|
|
//! This suite uses temp workspaces, temp PATH scripts, and loopback HTTP mocks only.
|
|
//! It must not call host Ollama, MLX, Python, Whisper, Piper, or model binaries.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::{Duration, Instant};
|
|
|
|
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 openhuman_core::core::all::RegisteredController;
|
|
use openhuman_core::openhuman::config::Config;
|
|
use openhuman_core::openhuman::inference::local::ops::{
|
|
local_ai_assets_status, local_ai_chat, local_ai_download_asset, local_ai_downloads_progress,
|
|
local_ai_prompt, local_ai_should_react, local_ai_transcribe, local_ai_transcribe_bytes,
|
|
LocalAiChatMessage,
|
|
};
|
|
use openhuman_core::openhuman::inference::local::{
|
|
all_local_inference_registered_controllers, LocalAiService,
|
|
};
|
|
use serde_json::{json, Value};
|
|
use tempfile::{tempdir, TempDir};
|
|
|
|
#[derive(Clone, Default)]
|
|
struct MockState {
|
|
requests: Arc<Mutex<Vec<(String, Value)>>>,
|
|
ollama_models: Arc<Mutex<Vec<String>>>,
|
|
whisper_mode: Arc<Mutex<WhisperMode>>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
enum WhisperMode {
|
|
#[default]
|
|
Valid,
|
|
TooSmall,
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
fn unset(key: &'static str) -> Self {
|
|
let previous = std::env::var_os(key);
|
|
// SAFETY: validation runs this integration test with --test-threads=1.
|
|
unsafe { std::env::remove_var(key) };
|
|
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 local_services_cover_mocked_inference_assets_speech_and_whisper_install() {
|
|
let _lock = env_lock();
|
|
let (base, state) = serve_mock().await;
|
|
let tmp = tempdir().expect("tempdir");
|
|
let scripts = tempdir().expect("scripts");
|
|
write_stub_script(
|
|
scripts.path(),
|
|
"whisper-cli",
|
|
"#!/bin/sh\nprintf 'mock whisper transcript\\n'\n",
|
|
);
|
|
write_stub_script(
|
|
scripts.path(),
|
|
"piper",
|
|
"#!/bin/sh\nwhile [ \"$#\" -gt 0 ]; do\n if [ \"$1\" = \"--output_file\" ]; then shift; out=\"$1\"; fi\n shift || true\ndone\ncat >/dev/null\nprintf 'RIFFmock' > \"$out\"\n",
|
|
);
|
|
write_stub_script(scripts.path(), "ollama", "#!/bin/sh\nexit 42\n");
|
|
write_stub_script(scripts.path(), "python", "#!/bin/sh\nexit 42\n");
|
|
write_stub_script(scripts.path(), "python3", "#!/bin/sh\nexit 42\n");
|
|
write_stub_script(scripts.path(), "mlx_lm.generate", "#!/bin/sh\nexit 42\n");
|
|
|
|
let mut config = temp_config(&tmp);
|
|
config.local_ai.runtime_enabled = true;
|
|
config.local_ai.opt_in_confirmed = true;
|
|
config.local_ai.provider = "ollama".to_string();
|
|
config.local_ai.base_url = Some(base.clone());
|
|
config.local_ai.selected_tier = Some("custom".to_string());
|
|
config.local_ai.chat_model_id = "gemma3:1b-it-qat".to_string();
|
|
config.local_ai.embedding_model_id = "bge-m3".to_string();
|
|
config.local_ai.vision_model_id = String::new();
|
|
config.local_ai.preload_embedding_model = false;
|
|
config.local_ai.preload_vision_model = false;
|
|
config.local_ai.preload_stt_model = false;
|
|
config.local_ai.preload_tts_voice = false;
|
|
config.local_ai.stt_model_id = "round21-stt.bin".to_string();
|
|
config.local_ai.stt_download_url = Some(format!("{base}/asset/stt"));
|
|
config.local_ai.whisper_in_process = false;
|
|
config.local_ai.tts_voice_id = "round21-voice".to_string();
|
|
config.local_ai.tts_download_url = Some(format!("{base}/asset/tts"));
|
|
config.local_ai.tts_config_download_url = Some(format!("{base}/asset/tts-json"));
|
|
config.save().await.expect("save config");
|
|
|
|
let _path = EnvVarGuard::set("PATH", scripts.path());
|
|
let _workspace = EnvVarGuard::set("OPENHUMAN_WORKSPACE", config.config_path.parent().unwrap());
|
|
let _ollama_base = EnvVarGuard::set("OPENHUMAN_OLLAMA_BASE_URL", &base);
|
|
let _whisper_models = EnvVarGuard::set("OPENHUMAN_WHISPER_MODELS_BASE_URL", &base);
|
|
let _ollama_bin = EnvVarGuard::unset("OLLAMA_BIN");
|
|
let _piper_bin = EnvVarGuard::unset("PIPER_BIN");
|
|
let _whisper_bin = EnvVarGuard::unset("WHISPER_BIN");
|
|
|
|
let service = LocalAiService::new(&config);
|
|
|
|
let initial_assets = service.assets_status(&config).await.expect("assets");
|
|
assert!(initial_assets.ollama_available);
|
|
assert_eq!(initial_assets.chat.state, "ready");
|
|
assert_eq!(initial_assets.vision.state, "disabled");
|
|
assert_eq!(initial_assets.embedding.state, "ready");
|
|
assert_eq!(initial_assets.stt.state, "ondemand");
|
|
assert_eq!(initial_assets.tts.state, "ondemand");
|
|
|
|
assert_eq!(
|
|
service
|
|
.prompt(&config, " say hello ", Some(12), true)
|
|
.await
|
|
.expect("prompt"),
|
|
"generated: say hello"
|
|
);
|
|
assert_eq!(
|
|
service
|
|
.summarize(&config, "decision: ship tests", Some(32))
|
|
.await
|
|
.expect("summary"),
|
|
"generated: Summarize this text in concise bullet points. Preserve decisions and commitments.\n\ndecision: ship tests"
|
|
);
|
|
assert_eq!(
|
|
service
|
|
.inline_complete(
|
|
&config,
|
|
"The patch",
|
|
"concise",
|
|
Some("technical"),
|
|
&["The patch adds tests".to_string()],
|
|
Some(8),
|
|
)
|
|
.await
|
|
.expect("inline"),
|
|
"adds tests"
|
|
);
|
|
|
|
let after_stt = service
|
|
.download_asset(&config, "stt")
|
|
.await
|
|
.expect("download stt");
|
|
assert_eq!(after_stt.stt.state, "ready");
|
|
let progress = service.downloads_progress(&config).await.expect("progress");
|
|
assert_eq!(progress.stt.state, "ready");
|
|
assert_eq!(progress.warning, Some("Downloading stt asset".to_string()));
|
|
let after_tts = service
|
|
.download_asset(&config, "tts")
|
|
.await
|
|
.expect("download tts");
|
|
assert_eq!(after_tts.tts.state, "ready");
|
|
|
|
let audio = tmp.path().join("audio.webm");
|
|
std::fs::write(&audio, b"not real audio").expect("audio");
|
|
let transcribed = service
|
|
.transcribe(&config, audio.to_string_lossy().as_ref())
|
|
.await
|
|
.expect("transcribe via mocked whisper-cli");
|
|
assert_eq!(transcribed.text, "mock whisper transcript");
|
|
assert_eq!(transcribed.model_id, "round21-stt.bin");
|
|
|
|
let tts_output = tmp.path().join("out").join("speech.wav");
|
|
let tts = service
|
|
.tts(
|
|
&config,
|
|
"hello from piper",
|
|
Some(tts_output.to_string_lossy().as_ref()),
|
|
)
|
|
.await
|
|
.expect("tts");
|
|
assert_eq!(tts.voice_id, "round21-voice");
|
|
assert!(tts_output.is_file());
|
|
|
|
let prompt_outcome = local_ai_prompt(&config, "ops prompt", Some(7), Some(true))
|
|
.await
|
|
.expect("ops prompt")
|
|
.value;
|
|
assert_eq!(prompt_outcome, "generated: ops prompt");
|
|
let chat_outcome = local_ai_chat(
|
|
&config,
|
|
vec![
|
|
LocalAiChatMessage {
|
|
role: "system".to_string(),
|
|
content: "stay short".to_string(),
|
|
},
|
|
LocalAiChatMessage {
|
|
role: "USER".to_string(),
|
|
content: "chat please".to_string(),
|
|
},
|
|
],
|
|
Some(20),
|
|
)
|
|
.await
|
|
.expect("ops chat")
|
|
.value;
|
|
assert_eq!(chat_outcome, "chat generated");
|
|
let rejected = local_ai_chat(
|
|
&config,
|
|
vec![LocalAiChatMessage {
|
|
role: "critic".to_string(),
|
|
content: "bad role".to_string(),
|
|
}],
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("bad chat role");
|
|
assert!(rejected.contains("unsupported message role"));
|
|
|
|
let reaction = local_ai_should_react(&config, "great work", "discord")
|
|
.await
|
|
.expect("reaction")
|
|
.value;
|
|
assert!(reaction.should_react);
|
|
assert_eq!(reaction.emoji.as_deref(), Some("⭐"));
|
|
|
|
assert_eq!(
|
|
local_ai_transcribe(&config, audio.to_string_lossy().as_ref())
|
|
.await
|
|
.expect("ops transcribe")
|
|
.value
|
|
.text,
|
|
"mock whisper transcript"
|
|
);
|
|
assert_eq!(
|
|
local_ai_transcribe_bytes(&config, b"audio bytes", Some(".WEBM".to_string()))
|
|
.await
|
|
.expect("ops transcribe bytes")
|
|
.value
|
|
.text,
|
|
"mock whisper transcript"
|
|
);
|
|
assert_eq!(
|
|
local_ai_assets_status(&config)
|
|
.await
|
|
.expect("ops assets")
|
|
.value
|
|
.stt
|
|
.state,
|
|
"ready"
|
|
);
|
|
assert_eq!(
|
|
local_ai_downloads_progress(&config)
|
|
.await
|
|
.expect("ops progress")
|
|
.value
|
|
.chat
|
|
.id,
|
|
"gemma3:1b-it-qat"
|
|
);
|
|
assert_eq!(
|
|
local_ai_download_asset(&config, "embedding")
|
|
.await
|
|
.expect("ops asset")
|
|
.value
|
|
.embedding
|
|
.state,
|
|
"ready"
|
|
);
|
|
|
|
let controllers = all_local_inference_registered_controllers();
|
|
let install = controller(&controllers, "install_whisper");
|
|
let status = controller(&controllers, "whisper_install_status");
|
|
|
|
set_whisper_mode(&state, WhisperMode::Valid);
|
|
let queued = call(install, json!({"model_size": "tiny", "force": true}))
|
|
.await
|
|
.expect("queue whisper install");
|
|
assert_eq!(queued["state"], "installing");
|
|
let installed = wait_for_whisper(status, |value| value["state"] == "installed").await;
|
|
assert_eq!(installed["progress"], 100);
|
|
assert_eq!(installed["stage"], "install complete");
|
|
|
|
let skipped = call(install, json!({"model_size": "tiny", "force": false}))
|
|
.await
|
|
.expect("queue whisper skip");
|
|
assert!(matches!(
|
|
skipped["state"].as_str(),
|
|
Some("installed") | Some("installing")
|
|
));
|
|
let skipped = wait_for_whisper(status, |value| {
|
|
value["state"] == "installed" && value["stage"] == "already installed"
|
|
})
|
|
.await;
|
|
assert_eq!(skipped["progress"], 100);
|
|
|
|
set_whisper_mode(&state, WhisperMode::TooSmall);
|
|
call(install, json!({"model_size": "smallfail", "force": true}))
|
|
.await
|
|
.expect("queue whisper failure");
|
|
let failed = wait_for_whisper(status, |value| value["state"] == "error").await;
|
|
assert!(failed["error_detail"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("downloaded payload too small"));
|
|
|
|
let seen = state.requests.lock().expect("requests").clone();
|
|
assert!(seen.iter().any(|(path, _)| path == "/api/generate"));
|
|
assert!(seen.iter().any(|(path, _)| path == "/api/chat"));
|
|
assert!(seen.iter().any(|(path, _)| path.ends_with("ggml-tiny.bin")));
|
|
assert!(seen
|
|
.iter()
|
|
.any(|(path, _)| path.ends_with("ggml-smallfail.bin")));
|
|
}
|
|
|
|
async fn serve_mock() -> (String, MockState) {
|
|
let state = MockState::default();
|
|
*state.ollama_models.lock().expect("models") = vec![
|
|
"gemma3:1b-it-qat".to_string(),
|
|
"bge-m3".to_string(),
|
|
"round21-vision".to_string(),
|
|
];
|
|
let app = Router::new()
|
|
.route("/api/tags", get(ollama_tags))
|
|
.route("/api/show", post(ollama_show))
|
|
.route("/api/pull", post(ollama_pull))
|
|
.route("/api/generate", post(ollama_generate))
|
|
.route("/api/chat", post(ollama_chat))
|
|
.route("/asset/stt", get(asset_stt))
|
|
.route("/asset/tts", get(asset_tts))
|
|
.route("/asset/tts-json", get(asset_tts_json))
|
|
.route("/{*path}", get(download_whisper))
|
|
.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("mock addr");
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.expect("serve mock");
|
|
});
|
|
(format!("http://{addr}"), state)
|
|
}
|
|
|
|
async fn ollama_tags(State(state): State<MockState>) -> impl IntoResponse {
|
|
let models = state
|
|
.ollama_models
|
|
.lock()
|
|
.expect("models")
|
|
.iter()
|
|
.map(|name| json!({ "name": name, "model": name }))
|
|
.collect::<Vec<_>>();
|
|
Json(json!({ "models": models }))
|
|
}
|
|
|
|
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": 8192,
|
|
"llama.context_length": 8192
|
|
}
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn ollama_pull(State(state): State<MockState>, Json(body): Json<Value>) -> impl IntoResponse {
|
|
let name = body["name"].as_str().unwrap_or_default().to_string();
|
|
if !name.is_empty() {
|
|
state.ollama_models.lock().expect("models").push(name);
|
|
}
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "application/x-ndjson")
|
|
.body(Body::from(
|
|
json!({"status":"success","total":10,"completed":10}).to_string() + "\n",
|
|
))
|
|
.expect("pull response")
|
|
}
|
|
|
|
async fn ollama_generate(
|
|
State(state): State<MockState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<Value>,
|
|
) -> impl IntoResponse {
|
|
remember(&state, "/api/generate", &headers, body.clone());
|
|
let prompt = body["prompt"].as_str().unwrap_or_default();
|
|
let system = body["system"].as_str().unwrap_or_default();
|
|
let response = if prompt.contains("single emoji character") {
|
|
"⭐".to_string()
|
|
} else if system.contains("inline text completion") {
|
|
"adds tests".to_string()
|
|
} else {
|
|
format!("generated: {}", prompt.trim())
|
|
};
|
|
Json(json!({
|
|
"response": response,
|
|
"done": true,
|
|
"prompt_eval_count": 2,
|
|
"prompt_eval_duration": 1_000_000,
|
|
"eval_count": 4,
|
|
"eval_duration": 2_000_000
|
|
}))
|
|
}
|
|
|
|
async fn ollama_chat(
|
|
State(state): State<MockState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<Value>,
|
|
) -> impl IntoResponse {
|
|
remember(&state, "/api/chat", &headers, body);
|
|
Json(json!({
|
|
"message": { "role": "assistant", "content": "chat generated" },
|
|
"done": true,
|
|
"prompt_eval_count": 1,
|
|
"prompt_eval_duration": 1_000_000,
|
|
"eval_count": 1,
|
|
"eval_duration": 1_000_000
|
|
}))
|
|
}
|
|
|
|
async fn asset_stt() -> impl IntoResponse {
|
|
bytes_response(vec![b's'; 4096])
|
|
}
|
|
|
|
async fn asset_tts() -> impl IntoResponse {
|
|
bytes_response(vec![b't'; 4096])
|
|
}
|
|
|
|
async fn asset_tts_json() -> impl IntoResponse {
|
|
bytes_response(br#"{"audio":{"sample_rate":22050}}"#.to_vec())
|
|
}
|
|
|
|
async fn download_whisper(
|
|
State(state): State<MockState>,
|
|
axum::extract::Path(path): axum::extract::Path<String>,
|
|
) -> impl IntoResponse {
|
|
remember_path(&state, &format!("/{path}"));
|
|
let mode = *state.whisper_mode.lock().expect("whisper mode");
|
|
match mode {
|
|
WhisperMode::Valid => bytes_response(vec![b'w'; 31 * 1024 * 1024]),
|
|
WhisperMode::TooSmall => bytes_response(vec![b'x'; 1024]),
|
|
}
|
|
}
|
|
|
|
fn bytes_response(bytes: Vec<u8>) -> Response<Body> {
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.body(Body::from(bytes))
|
|
.expect("bytes response")
|
|
}
|
|
|
|
fn remember(state: &MockState, path: &str, _headers: &HeaderMap, body: Value) {
|
|
state
|
|
.requests
|
|
.lock()
|
|
.expect("requests")
|
|
.push((path.to_string(), body));
|
|
}
|
|
|
|
fn remember_path(state: &MockState, path: &str) {
|
|
state
|
|
.requests
|
|
.lock()
|
|
.expect("requests")
|
|
.push((path.to_string(), Value::Null));
|
|
}
|
|
|
|
fn set_whisper_mode(state: &MockState, mode: WhisperMode) {
|
|
*state.whisper_mode.lock().expect("whisper mode") = mode;
|
|
}
|
|
|
|
async fn wait_for_whisper(status: &RegisteredController, done: impl Fn(&Value) -> bool) -> Value {
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
let mut last = Value::Null;
|
|
while Instant::now() < deadline {
|
|
last = call(status, json!({})).await.expect("status");
|
|
if done(&last) {
|
|
return last;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
panic!("timed out waiting for whisper status, last={last}");
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn write_stub_script(dir: &Path, name: &str, body: &str) -> PathBuf {
|
|
let path = dir.join(name);
|
|
std::fs::write(&path, body).expect("write stub");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(&path).expect("metadata").permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(&path, perms).expect("chmod");
|
|
}
|
|
path
|
|
}
|