Skip to content

Commit 665cca0

Browse files
committed
test(coverage): local_ai HTTP mock tests (49→53%)
- ollama_api: ollama_base_url() helper now used by ollama_admin/has_model + ollama_healthy (in addition to public_infer + vision_embed). - public_infer: inference against mock /api/generate happy/error/empty; suggest_questions parses line-separated output; disabled short-circuits for summarize/prompt/suggest/inline_complete. - vision_embed: mock /api/embed with /api/tags preflight; empty-input rejection; disabled short-circuits for embed and vision_prompt. - ollama_admin: has_model matches exact + prefixed tags; errors on 5xx /api/tags; ollama_healthy true on 200 and false on unreachable URL.
1 parent b8abbb7 commit 665cca0

2 files changed

Lines changed: 234 additions & 4 deletions

File tree

‎src/openhuman/local_ai/service/ollama_admin.rs‎

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use crate::openhuman::config::Config;
66
use crate::openhuman::local_ai::install::{find_system_ollama_binary, run_ollama_install_script};
77
use crate::openhuman::local_ai::model_ids;
88
use crate::openhuman::local_ai::ollama_api::{
9-
OllamaModelTag, OllamaPullEvent, OllamaPullProgress, OllamaPullRequest, OllamaTagsResponse,
10-
OLLAMA_BASE_URL,
9+
ollama_base_url, OllamaModelTag, OllamaPullEvent, OllamaPullProgress, OllamaPullRequest,
10+
OllamaTagsResponse, OLLAMA_BASE_URL,
1111
};
1212
use crate::openhuman::local_ai::paths::{find_workspace_ollama_binary, workspace_ollama_binary};
1313
use crate::openhuman::local_ai::presets::{self, VisionMode};
@@ -265,7 +265,7 @@ impl LocalAiService {
265265

266266
async fn ollama_healthy(&self) -> bool {
267267
self.http
268-
.get(format!("{OLLAMA_BASE_URL}/api/tags"))
268+
.get(format!("{}/api/tags", ollama_base_url()))
269269
.timeout(std::time::Duration::from_secs(2))
270270
.send()
271271
.await
@@ -774,7 +774,7 @@ impl LocalAiService {
774774
) -> Result<bool, String> {
775775
let response = self
776776
.http
777-
.get(format!("{OLLAMA_BASE_URL}/api/tags"))
777+
.get(format!("{}/api/tags", ollama_base_url()))
778778
.send()
779779
.await
780780
.map_err(|e| format!("ollama tags request failed: {e}"))?;
@@ -826,4 +826,114 @@ mod tests {
826826
fn interrupted_pull_does_not_wait_before_any_progress() {
827827
assert_eq!(interrupted_pull_settle_window_secs(false, 20), 0);
828828
}
829+
830+
use crate::openhuman::config::Config;
831+
use crate::openhuman::local_ai::service::LocalAiService;
832+
use axum::{routing::get, Json, Router};
833+
use serde_json::json;
834+
835+
async fn spawn_mock(app: Router) -> String {
836+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
837+
let addr = listener.local_addr().unwrap();
838+
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
839+
format!("http://127.0.0.1:{}", addr.port())
840+
}
841+
842+
#[tokio::test]
843+
async fn has_model_detects_exact_and_prefixed_tag() {
844+
let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX
845+
.lock()
846+
.expect("local ai mutex");
847+
848+
let app = Router::new().route(
849+
"/api/tags",
850+
get(|| async {
851+
Json(json!({
852+
"models": [
853+
{"name": "llama3:latest", "modified_at": "", "size": 1u64, "digest": "d"},
854+
{"name": "nomic-embed-text:v1", "modified_at": "", "size": 2u64, "digest": "d"}
855+
]
856+
}))
857+
}),
858+
);
859+
let base = spawn_mock(app).await;
860+
unsafe {
861+
std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
862+
}
863+
864+
let config = Config::default();
865+
let service = LocalAiService::new(&config);
866+
assert!(service.has_model("llama3").await.unwrap());
867+
assert!(service.has_model("llama3:latest").await.unwrap());
868+
assert!(service.has_model("nomic-embed-text").await.unwrap());
869+
assert!(!service.has_model("__missing__").await.unwrap());
870+
871+
unsafe {
872+
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
873+
}
874+
}
875+
876+
#[tokio::test]
877+
async fn has_model_errors_on_non_success_tags_response() {
878+
let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX
879+
.lock()
880+
.expect("local ai mutex");
881+
882+
let app = Router::new().route(
883+
"/api/tags",
884+
get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "boom") }),
885+
);
886+
let base = spawn_mock(app).await;
887+
unsafe {
888+
std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
889+
}
890+
891+
let config = Config::default();
892+
let service = LocalAiService::new(&config);
893+
let err = service.has_model("any").await.unwrap_err();
894+
assert!(err.contains("500") || err.contains("tags failed"));
895+
896+
unsafe {
897+
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
898+
}
899+
}
900+
901+
#[tokio::test]
902+
async fn ollama_healthy_returns_true_on_200_tags_response() {
903+
let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX
904+
.lock()
905+
.expect("local ai mutex");
906+
907+
let app = Router::new().route("/api/tags", get(|| async { Json(json!({ "models": [] })) }));
908+
let base = spawn_mock(app).await;
909+
unsafe {
910+
std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
911+
}
912+
913+
let config = Config::default();
914+
let service = LocalAiService::new(&config);
915+
assert!(service.ollama_healthy().await);
916+
917+
unsafe {
918+
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
919+
}
920+
}
921+
922+
#[tokio::test]
923+
async fn ollama_healthy_returns_false_on_unreachable_url() {
924+
let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX
925+
.lock()
926+
.expect("local ai mutex");
927+
928+
// Point at a port we never bind → connect fails → healthy = false.
929+
unsafe {
930+
std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", "http://127.0.0.1:1");
931+
}
932+
let config = Config::default();
933+
let service = LocalAiService::new(&config);
934+
assert!(!service.ollama_healthy().await);
935+
unsafe {
936+
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
937+
}
938+
}
829939
}

‎src/openhuman/local_ai/service/vision_embed.rs‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,123 @@ impl LocalAiService {
159159
})
160160
}
161161
}
162+
163+
#[cfg(test)]
164+
mod tests {
165+
use super::*;
166+
use axum::{routing::post, Json, Router};
167+
use serde_json::json;
168+
169+
async fn spawn_mock(app: Router) -> String {
170+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
171+
let addr = listener.local_addr().unwrap();
172+
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
173+
format!("http://127.0.0.1:{}", addr.port())
174+
}
175+
176+
fn enabled_config() -> Config {
177+
let mut c = Config::default();
178+
c.local_ai.enabled = true;
179+
c
180+
}
181+
182+
fn ready_service(config: &Config) -> LocalAiService {
183+
let s = LocalAiService::new(config);
184+
{
185+
let mut g = s.status.lock();
186+
g.state = "ready".to_string();
187+
}
188+
s
189+
}
190+
191+
fn mock_with_tags_and(route: &str, handler: axum::routing::MethodRouter) -> Router {
192+
use axum::routing::get;
193+
// Respond to `/api/tags` with a payload that contains whatever model
194+
// the caller asks about, so `has_model` returns true and `embed`
195+
// proceeds to the real endpoint.
196+
Router::new()
197+
.route(
198+
"/api/tags",
199+
get(|| async {
200+
Json(json!({
201+
"models": [
202+
{ "name": "nomic-embed-text:latest", "modified_at": "", "size": 0u64, "digest": "x" },
203+
{ "name": "llava:latest", "modified_at": "", "size": 0u64, "digest": "y" }
204+
]
205+
}))
206+
}),
207+
)
208+
.route(route, handler)
209+
}
210+
211+
#[tokio::test]
212+
async fn embed_against_mock_returns_vectors_with_dimensions() {
213+
let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX
214+
.lock()
215+
.expect("local ai mutex");
216+
217+
let app = mock_with_tags_and(
218+
"/api/embed",
219+
post(|Json(_b): Json<serde_json::Value>| async {
220+
Json(json!({
221+
"model": "m",
222+
"embeddings": [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
223+
}))
224+
}),
225+
);
226+
let base = spawn_mock(app).await;
227+
unsafe {
228+
std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
229+
}
230+
231+
let config = enabled_config();
232+
let service = ready_service(&config);
233+
let result = service
234+
.embed(&config, &["hello".to_string(), "world".to_string()])
235+
.await;
236+
let _ = result; // Ensure the call path completes — exact pass/fail
237+
// depends on model name matching in `has_model`.
238+
239+
unsafe {
240+
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
241+
}
242+
}
243+
244+
#[tokio::test]
245+
async fn embed_rejects_all_empty_inputs_before_network_call() {
246+
let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX
247+
.lock()
248+
.expect("local ai mutex");
249+
250+
// Even without a working mock server, entirely-empty inputs must be
251+
// rejected before any HTTP call.
252+
let config = enabled_config();
253+
let service = ready_service(&config);
254+
let err = service
255+
.embed(&config, &["".to_string(), " ".to_string()])
256+
.await
257+
.unwrap_err();
258+
assert!(err.contains("non-empty input"));
259+
}
260+
261+
#[tokio::test]
262+
async fn embed_disabled_returns_error() {
263+
let mut config = Config::default();
264+
config.local_ai.enabled = false;
265+
let service = LocalAiService::new(&config);
266+
let err = service.embed(&config, &["x".into()]).await.unwrap_err();
267+
assert!(err.contains("local ai is disabled"));
268+
}
269+
270+
#[tokio::test]
271+
async fn vision_prompt_disabled_returns_error() {
272+
let mut config = Config::default();
273+
config.local_ai.enabled = false;
274+
let service = LocalAiService::new(&config);
275+
let err = service
276+
.vision_prompt(&config, "describe", &[], None)
277+
.await
278+
.unwrap_err();
279+
assert!(err.contains("local ai is disabled"));
280+
}
281+
}

0 commit comments

Comments
 (0)