//! Round 23 raw/E2E coverage for inference voice/http/local-service gaps. //! //! This suite uses temp workspaces, fake binaries, and loopback HTTP/WS servers //! only. It must not call host Ollama, MLX, Python, Whisper, Piper, models, or //! download endpoints. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use axum::body::Body; use axum::extract::ws::WebSocketUpgrade; 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::{SinkExt, StreamExt}; use openhuman_core::core::types::AppState; 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::http; use openhuman_core::openhuman::inference::local::{ local_ai_assets_status, local_ai_downloads_progress, LocalAiService, }; use openhuman_core::openhuman::inference::voice::streaming::handle_dictation_ws; use serde_json::{json, Value}; use tempfile::{tempdir, TempDir}; use tokio_tungstenite::tungstenite::Message as WsMessage; #[derive(Clone, Default)] struct MockState { requests: Arc>>, } struct EnvVarGuard { key: &'static str, previous: Option, } impl EnvVarGuard { fn set(key: &'static str, value: impl AsRef) -> Self { let previous = std::env::var_os(key); // SAFETY: mutation is serialized by `env_lock()` (see below). unsafe { std::env::set_var(key, value) }; Self { key, previous } } fn unset(key: &'static str) -> Self { let previous = std::env::var_os(key); // SAFETY: mutation is serialized by `env_lock()` (see below). unsafe { std::env::remove_var(key) }; Self { key, previous } } } impl Drop for EnvVarGuard { fn drop(&mut self) { match &self.previous { Some(value) => { // SAFETY: mutation is serialized by `env_lock()` (see below). unsafe { std::env::set_var(self.key, value) } } None => { // SAFETY: mutation is serialized by `env_lock()` (see below). unsafe { std::env::remove_var(self.key) } } } } } /// Serializes the whole suite's process-global env access. /// /// `cargo test` and `cargo llvm-cov` run a binary's tests on multiple threads /// by default. These tests mutate `OPENHUMAN_WORKSPACE`, `OPENHUMAN_OLLAMA_BASE_URL`, /// and binary path env vars, so every test takes this guard before reading or /// writing config that may be influenced by process env. static ENV_LOCK: OnceLock> = OnceLock::new(); fn env_lock() -> std::sync::MutexGuard<'static, ()> { ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|e| e.into_inner()) } #[tokio::test] async fn http_models_and_chat_use_mocked_ollama_without_real_runtime() { let _env = env_lock(); let (base, state) = serve_mock().await; let tmp = tempdir().expect("tempdir"); let mut config = temp_config(&tmp); config.default_model = Some("reasoning-v1@0.9".to_string()); config.chat_provider = Some("ollama:route-chat@0.3".to_string()); config.reasoning_provider = Some("round23:cloud-chat@0.4".to_string()); config.local_ai.provider = "ollama".to_string(); config.local_ai.base_url = Some(base.clone()); config.local_ai.chat_model_id = "configured-chat".to_string(); config.cloud_providers = vec![CloudProviderCreds { id: "round23-id".to_string(), slug: "round23".to_string(), label: "Round 23".to_string(), endpoint: format!("{base}/cloud"), auth_style: CloudAuthStyle::None, legacy_type: None, default_model: Some("cloud-default@0.5".to_string()), }]; config.save().await.expect("save config"); let _workspace = EnvVarGuard::set("OPENHUMAN_WORKSPACE", config.config_path.parent().unwrap()); let _ollama_base = EnvVarGuard::set("OPENHUMAN_OLLAMA_BASE_URL", &base); store_app_session(&config); let app = http::router().with_state(AppState { core_version: "round23-test".to_string(), }); let url = serve_app(app).await; let client = reqwest::Client::new(); let models: Value = client .get(format!("{url}/models")) .send() .await .expect("models response") .json() .await .expect("models json"); let ids = models["data"] .as_array() .expect("models array") .iter() .map(|item| item["id"].as_str().unwrap_or_default().to_string()) .collect::>(); assert!(ids.contains(&"openhuman".to_string())); assert!(ids.contains(&"reasoning-v1".to_string())); assert!(ids.contains(&"ollama:configured-chat".to_string())); assert!(ids.contains(&"ollama:route-chat".to_string())); assert!(ids.contains(&"round23:cloud-chat".to_string())); assert!(!ids.iter().any(|id| id.contains('@'))); let chat: Value = client .post(format!("{url}/chat/completions")) .json(&json!({ "model": "bare-chat", "messages": [{ "role": "user", "content": "hello http" }], "temperature": 0.2 })) .send() .await .expect("chat response") .json() .await .expect("chat json"); assert_eq!( chat["choices"][0]["message"]["content"], "round23 chat bare-chat" ); assert_eq!(chat["model"], "bare-chat"); let stream_text = client .post(format!("{url}/chat/completions")) .json(&json!({ "model": "ollama:stream-chat", "stream": true, "messages": [{ "role": "user", "content": "stream please" }] })) .send() .await .expect("stream response") .text() .await .expect("stream text"); assert!( stream_text.contains("round23 stream"), "stream_text={stream_text}" ); assert!(stream_text.contains("[DONE]")); let bad: Value = client .post(format!("{url}/chat/completions")) .json(&json!({ "model": "ollama:", "messages": [] })) .send() .await .expect("bad response") .json() .await .expect("bad json"); assert!(bad["error"]["message"] .as_str() .unwrap_or_default() .contains("empty model")); let seen = state.requests.lock().expect("requests").clone(); assert!(seen .iter() .any(|(path, body)| path == "/v1/chat/completions" && body["model"] == "bare-chat")); } #[tokio::test] async fn dictation_ws_empty_stop_and_audio_cap_do_not_load_whisper() { let _env = env_lock(); let tmp = tempdir().expect("tempdir"); let mut config = temp_config(&tmp); config.dictation.streaming = false; config.dictation.llm_refinement = false; let ws_url = serve_dictation_ws(config).await; let (mut ws, _) = tokio_tungstenite::connect_async(&ws_url) .await .expect("connect empty dictation ws"); ws.send(WsMessage::Text(r#"{"type":"stop"}"#.to_string())) .await .expect("send stop"); let final_msg = ws.next().await.expect("final frame").expect("final ok"); let final_json: Value = serde_json::from_str(final_msg.to_text().expect("text frame")).expect("final json"); assert_eq!(final_json["type"], "final"); assert_eq!(final_json["text"], ""); assert_eq!(final_json["raw_text"], ""); let (mut ws, _) = tokio_tungstenite::connect_async(&ws_url) .await .expect("connect capped dictation ws"); ws.send(WsMessage::Binary(vec![0u8; 9_600_002])) .await .expect("send oversized pcm"); let error_msg = ws.next().await.expect("error frame").expect("error ok"); let error_json: Value = serde_json::from_str(error_msg.to_text().expect("text frame")).expect("error json"); assert_eq!(error_json["type"], "error"); assert!(error_json["message"] .as_str() .unwrap_or_default() .contains("Recording limit reached")); } #[tokio::test] async fn local_service_assets_and_whisper_fallback_use_fake_files_and_binaries() { let _env = env_lock(); let (base, _state) = serve_mock().await; let tmp = tempdir().expect("tempdir"); let scripts = tempdir().expect("scripts"); let whisper = write_stub_script( scripts.path(), "whisper-cli", "#!/bin/sh\nprintf 'fallback transcript from fake whisper\\n'\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"); write_stub_script(scripts.path(), "piper", "#!/bin/sh\nexit 42\n"); let fake_model = tmp.path().join("fake-ggml.bin"); std::fs::write(&fake_model, b"not a real whisper model").expect("fake model"); let audio = tmp.path().join("audio.wav"); std::fs::write(&audio, minimal_wav_16k_mono()).expect("audio wav"); 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 = "vision-ready".to_string(); config.local_ai.stt_model_id = fake_model.display().to_string(); config.local_ai.tts_voice_id = "round23-voice".to_string(); config.local_ai.tts_download_url = Some(format!("{base}/asset/tts")); config.local_ai.whisper_in_process = true; 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_bin = EnvVarGuard::set("WHISPER_BIN", &whisper); let _piper_bin = EnvVarGuard::unset("PIPER_BIN"); let _ollama_bin = EnvVarGuard::unset("OLLAMA_BIN"); let service = LocalAiService::new(&config); 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_eq!(assets.vision.state, "ondemand"); assert_eq!(assets.stt.state, "ready"); assert_eq!(assets.tts.state, "ondemand"); let progress = service.downloads_progress(&config).await.expect("progress"); assert_eq!(progress.stt.state, "ready"); assert_eq!(progress.tts.state, "ondemand"); let transcript = service .transcribe_with_prompt( &config, audio.to_string_lossy().as_ref(), Some("round23 vocabulary"), ) .await .expect("fake whisper fallback transcript"); assert_eq!(transcript.text, "fallback transcript from fake whisper"); assert_eq!(transcript.model_id, fake_model.display().to_string()); 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 .tts .state, "ondemand" ); } async fn serve_mock() -> (String, MockState) { let state = MockState::default(); let app = Router::new() .route("/v1/chat/completions", post(ollama_chat_completions)) .route("/api/tags", get(ollama_tags)) .route("/api/show", post(ollama_show)) .route("/asset/tts", get(asset_tts)) .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 serve_app(app: Router) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind app"); let addr = listener.local_addr().expect("app addr"); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve app"); }); format!("http://{addr}") } async fn serve_dictation_ws(config: Config) -> String { let config = Arc::new(config); let app = Router::new().route( "/ws/dictation", get({ let config = config.clone(); move |ws: WebSocketUpgrade| { let config = config.clone(); async move { ws.on_upgrade(move |socket| handle_dictation_ws(socket, config)) } } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind ws"); let addr = listener.local_addr().expect("ws addr"); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve ws"); }); format!("ws://{addr}/ws/dictation") } async fn ollama_chat_completions( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Response { remember(&state, "/v1/chat/completions", body.clone()); assert!( headers.get(header::AUTHORIZATION).is_none(), "ollama-compatible local requests should be authless" ); let model = body["model"].as_str().unwrap_or_default(); if body["stream"].as_bool().unwrap_or(false) { return sse_response([ json!({"choices":[{"delta":{"content":"round23 stream"}}]}), json!({"choices":[{"delta":{},"finish_reason":"stop"}]}), ]); } Json(json!({ "id": "mock-chat", "object": "chat.completion", "choices": [{ "message": { "role": "assistant", "content": format!("round23 chat {model}") } }] })) .into_response() } async fn ollama_tags() -> impl IntoResponse { Json(json!({ "models": [ { "name": "configured-chat", "model": "configured-chat" }, { "name": "gemma3:1b-it-qat", "model": "gemma3:1b-it-qat" }, { "name": "bge-m3", "model": "bge-m3" }, { "name": "vision-ready", "model": "vision-ready" } ] })) } async fn ollama_show(Json(body): Json) -> 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": 4096, "llama.context_length": 4096 } })) .into_response() } async fn asset_tts() -> impl IntoResponse { Response::builder() .status(StatusCode::OK) .header(header::CONTENT_LENGTH, "12") .body(Body::from("voice-bytes!")) .expect("tts response") } fn sse_response(events: [Value; N]) -> Response { let mut body = events .into_iter() .map(|event| format!("data: {}\n\n", event)) .collect::(); body.push_str("data: [DONE]\n\n"); Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/event-stream") .body(Body::from(body)) .expect("sse response") } fn remember(state: &MockState, path: &str, body: Value) { state .requests .lock() .expect("requests") .push((path.to_string(), body)); } 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 store_app_session(config: &Config) { AuthService::from_config(config) .store_provider_token( APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, "round23-session-token", HashMap::new(), true, ) .expect("store app session"); } 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 } fn minimal_wav_16k_mono() -> Vec { let pcm: [i16; 4] = [0, 100, -100, 0]; let data_len = (pcm.len() * 2) as u32; let mut out = Vec::new(); out.extend_from_slice(b"RIFF"); out.extend_from_slice(&(36 + data_len).to_le_bytes()); out.extend_from_slice(b"WAVEfmt "); out.extend_from_slice(&16u32.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&16000u32.to_le_bytes()); out.extend_from_slice(&32000u32.to_le_bytes()); out.extend_from_slice(&2u16.to_le_bytes()); out.extend_from_slice(&16u16.to_le_bytes()); out.extend_from_slice(b"data"); out.extend_from_slice(&data_len.to_le_bytes()); for sample in pcm { out.extend_from_slice(&sample.to_le_bytes()); } out }