Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c700cb7
fix(engine): default OpenAI-compatible engine hosts to 127.0.0.1
LuigiElleBalotta Jun 11, 2026
0806026
fix(engine): default Ollama host to 127.0.0.1 (avoid Windows localhos…
LuigiElleBalotta Jun 11, 2026
3636c0a
fix(engine): bump health() timeout 2.0s -> 3.0s as a safety margin
LuigiElleBalotta Jun 11, 2026
e10b824
fix(test): update TestOllamaToolCalls mocks to 127.0.0.1
LuigiElleBalotta Jun 11, 2026
73d506e
fix(config): default per-engine host configs to 127.0.0.1
LuigiElleBalotta Jun 11, 2026
d7e15df
fix(config): generated config.toml templates default to 127.0.0.1
LuigiElleBalotta Jun 11, 2026
871f01e
fix(cli): jarvis init engine-detection probes default to 127.0.0.1
LuigiElleBalotta Jun 11, 2026
6da05ba
fix(desktop): default custom-engine host to 127.0.0.1 in Settings
LuigiElleBalotta Jun 11, 2026
efe5839
docs(engine): update default hosts to 127.0.0.1, health timeout to 3s
LuigiElleBalotta Jun 11, 2026
03ff7c3
fix(engine): default _OpenAICompatibleEngine host + Ollama model fetc…
LuigiElleBalotta Jun 12, 2026
91da7a5
feat(desktop): add config_missing helper for first-run detection
LuigiElleBalotta Jun 12, 2026
a7d73eb
feat(desktop): add probe_local_engines command for onboarding
LuigiElleBalotta Jun 12, 2026
74e5ed5
feat(desktop): skip auto-boot on first run, enter onboarding phase
LuigiElleBalotta Jun 12, 2026
960b3b8
fix(desktop): reset setup status before retrying boot_backend
LuigiElleBalotta Jun 12, 2026
c79e724
docs(desktop): clarify start_backend status-reset comment
LuigiElleBalotta Jun 12, 2026
a6851a7
feat(desktop): add probeLocalEngines/startBackend API wrappers
LuigiElleBalotta Jun 12, 2026
536a363
refactor(settings): extract InferenceSourceForm component
LuigiElleBalotta Jun 12, 2026
eb0c984
feat(desktop): add first-run OnboardingScreen
LuigiElleBalotta Jun 12, 2026
6f121f6
fix(desktop): disable inference-source form submit while busy
LuigiElleBalotta Jun 12, 2026
267e6c6
feat(desktop): wire OnboardingScreen + Reconfigure into SetupScreen
LuigiElleBalotta Jun 12, 2026
14b276f
fix(desktop): refresh setup status immediately after Reconfigure retry
LuigiElleBalotta Jun 12, 2026
1537844
fix(desktop): avoid try_lock for onboarding phase set in .setup()
LuigiElleBalotta Jun 15, 2026
4afe146
chore: refresh tsc build cache and uv lockfile
LuigiElleBalotta Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions docs/architecture/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ All providers produce the same output format consumed by agents:

The Ollama backend communicates via Ollama's native HTTP API at `/api/chat` and `/api/tags`. It is the default engine on Apple Silicon and consumer NVIDIA GPUs.

- **Default host:** `http://localhost:11434`
- **Default host:** `http://127.0.0.1:11434`
- **Health check:** `GET /api/tags`
- **Model listing:** `GET /api/tags` (extracts model names)
- **Tool support:** Passes `tools` in the request payload and extracts `tool_calls` from responses
Expand All @@ -135,22 +135,22 @@ The Ollama backend communicates via Ollama's native HTTP API at `/api/chat` and

The vLLM backend uses the OpenAI-compatible `/v1/chat/completions` API. It is recommended for datacenter GPUs (NVIDIA A100, H100, L40, A10, A30 and AMD MI300, MI325, MI350, MI355).

- **Default host:** `http://localhost:13305`
- **Default host:** `http://127.0.0.1:13305`
- **Health check:** `GET /v1/models`
- **Tool fallback:** If the server returns HTTP 400 when tools are included, the engine automatically retries without tools

### SGLang

The SGLang backend also uses the OpenAI-compatible API. It shares the same `_OpenAICompatibleEngine` base class as vLLM and llama.cpp.

- **Default host:** `http://localhost:30000`
- **Default host:** `http://127.0.0.1:30000`
- **Health check:** `GET /v1/models`

### llama.cpp

The llama.cpp backend connects to a `llama-server` instance via the OpenAI-compatible API. It is recommended for CPU-only systems and GGUF-quantized models.

- **Default host:** `http://localhost:8080`
- **Default host:** `http://127.0.0.1:8080`
- **Health check:** `GET /v1/models`

### Cloud
Expand All @@ -170,23 +170,23 @@ The Cloud backend provides access to OpenAI, Anthropic, and Google models via th

The MLX backend serves models via the MLX framework on Apple Silicon. It uses the OpenAI-compatible `/v1/chat/completions` API.

- **Default host:** `http://localhost:8080`
- **Default host:** `http://127.0.0.1:8080`
- **Health check:** `GET /v1/models`
- **Best for:** Apple Silicon Macs (M1/M2/M3/M4) running MLX-format or GGUF models natively

### LM Studio

The LM Studio backend connects to the LM Studio desktop application's built-in server, which exposes an OpenAI-compatible API.

- **Default host:** `http://localhost:1234`
- **Default host:** `http://127.0.0.1:1234`
- **Health check:** `GET /v1/models`
- **Best for:** Users who prefer a GUI for model management and want a zero-configuration local server

### Exo

The Exo backend connects to the Exo distributed inference runtime, which partitions model layers across multiple heterogeneous devices (e.g., a Mac and a Linux box). Exo supports Apple Silicon, NVIDIA, and AMD GPUs.

- **Default host:** `http://localhost:52415`
- **Default host:** `http://127.0.0.1:52415`
- **Health check:** `GET /v1/models`
- **Install:** `pip install exo` or from source at [github.com/exo-explore/exo](https://github.com/exo-explore/exo)
- **Best for:** Running models too large for a single device by distributing across multiple Apple Silicon or heterogeneous machines
Expand All @@ -195,7 +195,7 @@ The Exo backend connects to the Exo distributed inference runtime, which partiti

The Nexa backend connects to the Nexa SDK on-device inference server via a FastAPI shim (`nexa_shim.py`). It wraps `nexaai.LLM` as an OpenAI-compatible API on port 18181.

- **Default host:** `http://localhost:18181`
- **Default host:** `http://127.0.0.1:18181`
- **Health check:** `GET /v1/models`
- **Install:** `pip install nexaai`
- **Best for:** On-device inference with GGUF models on Apple Silicon or CPU
Expand All @@ -204,7 +204,7 @@ The Nexa backend connects to the Nexa SDK on-device inference server via a FastA

The Lemonade backend connects to the [Lemonade](https://lemonade-server.ai/) inference server, which is optimized for AMD consumer GPUs (RDNA architecture) and Ryzen AI Neural Processing Units (NPUs). It uses the OpenAI-compatible `/v1/chat/completions` API.

- **Default host:** `http://localhost:13305`
- **Default host:** `http://127.0.0.1:13305`
- **Health check:** `GET /v1/models`
- **Install:** Visit [lemonade-server.ai](https://lemonade-server.ai/) for platform-specific installation instructions
- **Best for:** Ryzen AI GPUs and NPUs, and AMD-based desktop and laptop systems
Expand All @@ -213,7 +213,7 @@ The Lemonade backend connects to the [Lemonade](https://lemonade-server.ai/) inf

The Uzu backend connects to the Uzu inference runtime. Unlike other OpenAI-compatible engines, Uzu serves its API at the root path (no `/v1` prefix).

- **Default host:** `http://localhost:8000`
- **Default host:** `http://127.0.0.1:8000`
- **API prefix:** (none — endpoints are `/chat/completions`, `/models`)
- **Health check:** `GET /models`
- **Best for:** Uzu-optimized inference workloads
Expand All @@ -225,7 +225,7 @@ The Apple FM backend connects to Apple's Foundation Model SDK via a FastAPI shim
!!! note "Token counts"
The Apple FM SDK does not expose token counts. The shim returns 0 for all token counts. Benchmark throughput and energy-per-token metrics will reflect this limitation.

- **Default host:** `http://localhost:8079`
- **Default host:** `http://127.0.0.1:8079`
- **Health check:** `GET /v1/models`
- **Install:** `pip install python-apple-fm-sdk`
- **Best for:** Running Apple Foundation Models natively on Apple Silicon hardware
Expand Down Expand Up @@ -318,7 +318,7 @@ The `_OpenAICompatibleEngine` base class provides a shared implementation for en
```python
class _OpenAICompatibleEngine(InferenceEngine):
engine_id: str = ""
_default_host: str = "http://localhost:8000"
_default_host: str = "http://127.0.0.1:8000"

def __init__(self, host: str | None = None, *, timeout: float = 120.0):
self._host = (host or self._default_host).rstrip("/")
Expand All @@ -330,7 +330,7 @@ Key behaviors:
- **Synchronous generation:** `POST /v1/chat/completions` with `stream=False`
- **Streaming:** `POST /v1/chat/completions` with `stream=True`, parsing SSE `data:` lines
- **Model listing:** `GET /v1/models`, extracting `data[].id`
- **Health check:** `GET /v1/models` with a 2-second timeout
- **Health check:** `GET /v1/models` with a 3-second timeout
- **Tool call fallback:** On HTTP 400 with tools in the payload, retries without tools (handles engines that do not support function calling)

---
Expand All @@ -344,33 +344,33 @@ Engine hosts and defaults are configured in `~/.openjarvis/config.toml` using **
default = "ollama"

[engine.ollama]
host = "http://localhost:11434"
host = "http://127.0.0.1:11434"

[engine.vllm]
host = "http://localhost:8000"
host = "http://127.0.0.1:8000"

[engine.sglang]
host = "http://localhost:30000"
host = "http://127.0.0.1:30000"

# [engine.llamacpp]
# host = "http://localhost:8080"
# host = "http://127.0.0.1:8080"
# binary_path = ""

# [engine.lemonade]
# host = "http://localhost:13305"
# host = "http://127.0.0.1:13305"
```

The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settings:

| Config Class | Field | Default | Description |
|---|---|---|---|
| `EngineConfig` | `default` | `"ollama"` (hardware-dependent) | Preferred engine backend |
| `OllamaEngineConfig` | `host` | `http://localhost:11434` | Ollama server URL |
| `VLLMEngineConfig` | `host` | `http://localhost:8000` | vLLM server URL |
| `SGLangEngineConfig` | `host` | `http://localhost:30000` | SGLang server URL |
| `LlamaCppEngineConfig` | `host` | `http://localhost:8080` | llama.cpp server URL |
| `OllamaEngineConfig` | `host` | `http://127.0.0.1:11434` | Ollama server URL |
| `VLLMEngineConfig` | `host` | `http://127.0.0.1:8000` | vLLM server URL |
| `SGLangEngineConfig` | `host` | `http://127.0.0.1:30000` | SGLang server URL |
| `LlamaCppEngineConfig` | `host` | `http://127.0.0.1:8080` | llama.cpp server URL |
| `LlamaCppEngineConfig` | `binary_path` | `""` | Path to llama.cpp binary (for managed mode) |
| `LemonadeEngineConfig` | `host` | `http://localhost:13305` | Lemonade server URL |
| `LemonadeEngineConfig` | `host` | `http://127.0.0.1:13305` | Lemonade server URL |

!!! note "Backward compatibility"
The old flat field names `ollama_host`, `vllm_host`, `llamacpp_host`, `llamacpp_path`, `sglang_host`, and `lemonade_host` under `[engine]` are still accepted as backward-compatible properties on `EngineConfig`. New configurations should use the nested sub-section format.
Expand Down
161 changes: 156 additions & 5 deletions frontend/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,92 @@ async fn endpoint_reachable(host: &str, timeout: Duration) -> bool {
false
}

/// Result of probing a local inference server for first-run onboarding.
#[derive(serde::Serialize, Clone)]
struct ProbeResult {
/// "ollama" | "lmstudio" — matches the frontend's `ProbeResult['engine']`.
engine: String,
label: String,
host: String,
found: bool,
model: Option<String>,
}

/// Given an LM Studio `/v1/models` response body, decide whether it counts
/// as "found" (server up AND at least one model loaded) and which model id
/// to suggest. Split out from `probe_lmstudio` so this decision is testable
/// without real network I/O. A server that's up but has no model loaded is
/// treated as not-found, since `jarvis serve --engine lmstudio --model ""`
/// would fail.
fn lmstudio_probe_result(body: &serde_json::Value) -> (bool, Option<String>) {
let model = body
.get("data")
.and_then(|d| d.as_array())
.and_then(|arr| arr.first())
.and_then(|m| m.get("id"))
.and_then(|id| id.as_str())
.map(|s| s.to_string());
(model.is_some(), model)
}

async fn probe_ollama() -> ProbeResult {
let host = "http://127.0.0.1:11434";
let found = match reqwest::Client::builder()
.timeout(Duration::from_secs(1))
.build()
{
Ok(client) => client
.get(format!("{host}/api/tags"))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false),
Err(_) => false,
};
ProbeResult {
engine: "ollama".into(),
label: "Ollama".into(),
host: host.into(),
found,
model: None,
}
}

async fn probe_lmstudio() -> ProbeResult {
let host = "http://127.0.0.1:1234";
let (found, model) = match reqwest::Client::builder()
.timeout(Duration::from_secs(1))
.build()
{
Ok(client) => match client.get(format!("{host}/v1/models")).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>().await {
Ok(body) => lmstudio_probe_result(&body),
Err(_) => (false, None),
}
}
_ => (false, None),
},
Err(_) => (false, None),
};
ProbeResult {
engine: "lmstudio".into(),
label: "LM Studio".into(),
host: host.into(),
found,
model,
}
}

/// Probe Ollama and LM Studio on their default localhost ports for the
/// first-run onboarding screen. Runs both concurrently — cheap now that
/// everything is `127.0.0.1` (see docs/architecture/engine.md).
#[tauri::command]
async fn probe_local_engines() -> Vec<ProbeResult> {
let (ollama, lmstudio) = tokio::join!(probe_ollama(), probe_lmstudio());
vec![ollama, lmstudio]
}

/// Outcome of waiting for `jarvis serve` to become healthy.
///
/// Unlike [`wait_for_url`] this differentiates "server is up but degraded"
Expand Down Expand Up @@ -1352,6 +1438,11 @@ async fn start_backend(
) -> Result<(), String> {
let b = backend.inner().clone();
let s = status.inner().clone();
// Reset before respawning: covers both the onboarding first-start
// (status currently holds phase: "onboarding") and Reconfigure retries
// (status may hold a stale error/phase/readiness from the previous
// failed attempt).
*s.lock().await = SetupStatus::default();
tauri::async_runtime::spawn(boot_backend(b, s));
Ok(())
}
Expand Down Expand Up @@ -1903,6 +1994,17 @@ fn inference_config_path() -> std::path::PathBuf {
.join("inference.json")
}

/// True if `path` does not exist — pure wrapper so the first-run check is
/// testable without touching the real `~/.openjarvis` directory.
fn config_missing_at(path: &std::path::Path) -> bool {
!path.exists()
}

/// True on first run: `inference.json` has never been written.
fn config_missing() -> bool {
config_missing_at(&inference_config_path())
}

/// Parse config text. Any error (missing/garbage) yields the Ollama default —
/// a broken file must never strand the user with no working inference source.
fn parse_inference_config(text: &str) -> InferenceConfig {
Expand Down Expand Up @@ -2455,13 +2557,24 @@ pub fn run() {
}
}

// Auto-start backend services on launch
tauri::async_runtime::spawn(boot_backend(boot_backend_ref, boot_status_ref));
// Auto-start backend services on launch — unless this is a
// first run with no inference.json yet. In that case
// OnboardingScreen probes for a running engine and calls
// `start_backend` once the user picks a source (see
// probe_local_engines / start_backend).
if config_missing() {
tauri::async_runtime::spawn(async move {
boot_status_ref.lock().await.phase = "onboarding".into();
});
} else {
tauri::async_runtime::spawn(boot_backend(boot_backend_ref, boot_status_ref));
}

Ok(())
})
.invoke_handler(tauri::generate_handler![
get_setup_status,
probe_local_engines,
get_api_base,
start_backend,
stop_backend,
Expand Down Expand Up @@ -2510,9 +2623,9 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::{
boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error,
normalize_host, parse_inference_config, upsert_engine_host, uv_sync_stderr_tail,
InferenceConfig, SourceKind,
boot_plan, config_missing_at, default_local_model, format_uv_sync_failure,
format_uv_sync_spawn_error, lmstudio_probe_result, normalize_host, parse_inference_config,
upsert_engine_host, uv_sync_stderr_tail, InferenceConfig, SourceKind,
};
use std::path::Path;

Expand Down Expand Up @@ -2611,6 +2724,44 @@ mod tests {
assert_eq!(cfg.engine.as_deref(), Some("lmstudio"));
}

#[test]
fn config_missing_at_reports_existence() {
let dir = std::env::temp_dir().join(format!("ojs-onboarding-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let missing = dir.join("does-not-exist.json");
let present = dir.join("present.json");
std::fs::write(&present, "{}").unwrap();

assert!(config_missing_at(&missing));
assert!(!config_missing_at(&present));

let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn lmstudio_probe_result_detects_loaded_model() {
let body = serde_json::json!({"data": [{"id": "qwen2.5-7b-instruct"}, {"id": "other"}]});
let (found, model) = lmstudio_probe_result(&body);
assert!(found);
assert_eq!(model.as_deref(), Some("qwen2.5-7b-instruct"));
}

#[test]
fn lmstudio_probe_result_empty_data_is_not_found() {
let body = serde_json::json!({"data": []});
let (found, model) = lmstudio_probe_result(&body);
assert!(!found);
assert!(model.is_none());
}

#[test]
fn lmstudio_probe_result_missing_data_is_not_found() {
let body = serde_json::json!({});
let (found, model) = lmstudio_probe_result(&body);
assert!(!found);
assert!(model.is_none());
}

#[test]
fn normalize_host_strips_trailing_slash_and_v1() {
assert_eq!(normalize_host("http://localhost:1234/v1"), "http://localhost:1234");
Expand Down
Loading