From 5411f19e4783e955250c2c730252b92f722edf4f Mon Sep 17 00:00:00 2001 From: Zavian Wang <36817799+Zavianx@users.noreply.github.com> Date: Sat, 16 May 2026 02:54:17 +0800 Subject: [PATCH] Add LM Studio as a local model provider (#1750) Co-authored-by: Steven Enamakel --- .env.example | 3 + app/.prettierignore | 1 + app/scripts/e2e-run-session.sh | 23 +- .../__tests__/CoreStateProvider.test.tsx | 2 + .../services/api/__tests__/authApi.test.ts | 3 +- app/src/utils/tauriCommands/config.test.ts | 11 +- app/src/utils/tauriCommands/config.ts | 5 + docs/TEST-COVERAGE-MATRIX.md | 5 +- docs/agent-workflows/codex-pr-checklist.md | 2 +- gitbooks/README.md | 2 +- gitbooks/features/model-routing/local-ai.md | 26 +- scripts/codex-pr-preflight.mjs | 17 +- scripts/test-codex-pr-preflight.mjs | 4 +- src/api/config.rs | 176 +++++--- src/openhuman/about_app/catalog.rs | 10 + src/openhuman/about_app/catalog_tests.rs | 1 + .../accessibility/automation_state.rs | 23 +- .../accessibility/permissions_tests.rs | 10 +- src/openhuman/agent/triage/escalation.rs | 125 ++---- src/openhuman/agent/triage/events.rs | 65 ++- src/openhuman/config/ops.rs | 25 ++ src/openhuman/config/ops_tests.rs | 56 +++ src/openhuman/config/schema/load.rs | 15 +- src/openhuman/config/schema/load_tests.rs | 47 +-- src/openhuman/config/schema/local_ai.rs | 10 +- src/openhuman/config/schemas.rs | 26 +- src/openhuman/config/schemas_tests.rs | 27 ++ src/openhuman/local_ai/README.md | 13 +- src/openhuman/local_ai/lm_studio_api.rs | 231 ++++++++++ src/openhuman/local_ai/mod.rs | 4 +- src/openhuman/local_ai/model_ids.rs | 62 ++- src/openhuman/local_ai/provider.rs | 57 +++ src/openhuman/local_ai/service/assets.rs | 397 ++++++++++++++++-- src/openhuman/local_ai/service/bootstrap.rs | 169 +++++++- src/openhuman/local_ai/service/lm_studio.rs | 246 +++++++++++ src/openhuman/local_ai/service/mod.rs | 1 + .../local_ai/service/ollama_admin.rs | 97 +++++ .../local_ai/service/ollama_admin_tests.rs | 171 ++++++++ .../local_ai/service/public_infer.rs | 73 ++++ .../local_ai/service/public_infer_tests.rs | 107 +++++ src/openhuman/local_ai/types.rs | 18 +- src/openhuman/routing/factory.rs | 50 ++- src/openhuman/routing/provider.rs | 7 +- src/openhuman/screen_intelligence/tests.rs | 9 +- tests/json_rpc_e2e.rs | 152 +++++++ 45 files changed, 2281 insertions(+), 303 deletions(-) create mode 100644 src/openhuman/local_ai/lm_studio_api.rs create mode 100644 src/openhuman/local_ai/provider.rs create mode 100644 src/openhuman/local_ai/service/lm_studio.rs diff --git a/.env.example b/.env.example index bf5764451..483828077 100644 --- a/.env.example +++ b/.env.example @@ -134,6 +134,9 @@ OPENHUMAN_PROXY_SERVICES= # [optional] Override selected model tier: low, medium, high # Applies the corresponding preset at config load time (overrides config.toml). OPENHUMAN_LOCAL_AI_TIER= +# [optional] Override LM Studio's OpenAI-compatible local server base URL. +# Defaults to http://localhost:1234/v1 when local_ai.provider = "lm_studio". +OPENHUMAN_LM_STUDIO_BASE_URL= # --------------------------------------------------------------------------- # Local AI binary overrides diff --git a/app/.prettierignore b/app/.prettierignore index 9266c6331..8e4bde1b6 100644 --- a/app/.prettierignore +++ b/app/.prettierignore @@ -11,3 +11,4 @@ tsconfig.tsbuildinfo yarn.lock package-lock.json target-test-run +test/e2e/artifacts diff --git a/app/scripts/e2e-run-session.sh b/app/scripts/e2e-run-session.sh index 4fee42b96..187a628bb 100755 --- a/app/scripts/e2e-run-session.sh +++ b/app/scripts/e2e-run-session.sh @@ -73,6 +73,8 @@ if [ "${OPENHUMAN_SERVICE_MOCK:-0}" = "1" ] && [ -z "${OPENHUMAN_SERVICE_MOCK_ST fi cleanup() { + local status=$? + set +e if [ -n "$APPIUM_PID" ]; then echo "[runner] Stopping Appium (pid $APPIUM_PID)..." kill "$APPIUM_PID" 2>/dev/null || true @@ -108,21 +110,26 @@ cleanup() { fi fi if [ -n "$CREATED_TEMP_WORKSPACE" ]; then - # Tolerate transient races: even after the kill above, a CEF helper - # may still be flushing CEF/Default/* on a slow Linux runner. The - # workspace is a per-run mktemp under /tmp; anything left behind is - # collected by the next CI tmp-cleanup pass. We must not fail the - # whole job on cleanup leftovers when the test itself passed. - rm -rf "$CREATED_TEMP_WORKSPACE" 2>/dev/null || true + for attempt in 1 2 3; do + rm -rf "$CREATED_TEMP_WORKSPACE" 2>/dev/null && break + echo "[runner] Warning: temporary workspace cleanup failed (attempt $attempt): $CREATED_TEMP_WORKSPACE" >&2 + sleep "$attempt" + done + if [ -e "$CREATED_TEMP_WORKSPACE" ]; then + echo "[runner] Warning: leaving temporary workspace after cleanup retries: $CREATED_TEMP_WORKSPACE" >&2 + fi fi if [ -n "$CREATED_TEMP_CEF_CACHE" ]; then rm -rf "$CREATED_TEMP_CEF_CACHE" 2>/dev/null || true fi if [ -n "$E2E_CONFIG_BACKUP" ] && [ -f "$E2E_CONFIG_BACKUP" ]; then - mv "$E2E_CONFIG_BACKUP" "$E2E_CONFIG_FILE" + mv "$E2E_CONFIG_BACKUP" "$E2E_CONFIG_FILE" \ + || echo "[runner] Warning: failed to restore E2E config backup: $E2E_CONFIG_BACKUP" >&2 elif [ -n "$E2E_CONFIG_FILE" ] && [ -f "$E2E_CONFIG_FILE" ]; then - rm -f "$E2E_CONFIG_FILE" + rm -f "$E2E_CONFIG_FILE" \ + || echo "[runner] Warning: failed to remove generated E2E config: $E2E_CONFIG_FILE" >&2 fi + return "$status" } trap cleanup EXIT diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index afcb54922..334de67f5 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as coreStateApi from '../../services/coreStateApi'; import * as tauriCommands from '../../utils/tauriCommands'; import { setCoreStateSnapshot } from '../../lib/coreState/store'; +import { setActiveUserId } from '../../store/userScopedStorage'; import CoreStateProvider, { coreStatePollFailureWarningMessage, useCoreState, @@ -104,6 +105,7 @@ describe('CoreStateProvider — identity-change cache clearing', () => { getTeamMembers.mockReset(); getTeamInvites.mockReset(); resetCoreStateStore(); + setActiveUserId(null); }); it('clears teams/members/invites when the userId changes between refreshes', async () => { diff --git a/app/src/services/api/__tests__/authApi.test.ts b/app/src/services/api/__tests__/authApi.test.ts index 04743aab0..a051597a9 100644 --- a/app/src/services/api/__tests__/authApi.test.ts +++ b/app/src/services/api/__tests__/authApi.test.ts @@ -15,7 +15,8 @@ describe('sendEmailMagicLink', () => { await sendEmailMagicLink('user@example.com', 'openhuman://'); - expect(fetchSpy).toHaveBeenCalledWith('http://localhost:5005/auth/email/send-link', { + const backendUrl = process.env.VITEST_MOCK_API_URL ?? 'http://localhost:5005'; + expect(fetchSpy).toHaveBeenCalledWith(`${backendUrl}/auth/email/send-link`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-web-version': '0.0.0-test' }, body: JSON.stringify({ email: 'user@example.com', frontendRedirectUri: 'openhuman://' }), diff --git a/app/src/utils/tauriCommands/config.test.ts b/app/src/utils/tauriCommands/config.test.ts index 1befed7dc..b89586590 100644 --- a/app/src/utils/tauriCommands/config.test.ts +++ b/app/src/utils/tauriCommands/config.test.ts @@ -40,7 +40,16 @@ describe('tauriCommands/config', () => { result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, logs: [], }); - const patch = { runtime_enabled: true, usage_embeddings: true, usage_subconscious: false }; + const patch = { + runtime_enabled: true, + opt_in_confirmed: true, + provider: 'lm_studio', + base_url: 'http://localhost:1234/v1', + model_id: 'local-model', + chat_model_id: 'local-model', + usage_embeddings: true, + usage_subconscious: false, + }; await openhumanUpdateLocalAiSettings(patch); expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.config_update_local_ai_settings', diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 38f5a6770..880959233 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -88,6 +88,11 @@ export interface ScreenIntelligenceSettingsUpdate { export interface LocalAiSettingsUpdate { runtime_enabled?: boolean | null; + opt_in_confirmed?: boolean | null; + provider?: string | null; + base_url?: string | null; + model_id?: string | null; + chat_model_id?: string | null; usage_embeddings?: boolean | null; usage_heartbeat?: boolean | null; usage_learning_reflection?: boolean | null; diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index b040b303b..b10152ca7 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -114,7 +114,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil --- -## 3. Local AI Runtime (Ollama) +## 3. Local AI Runtime (Ollama + LM Studio) ### 3.1 Model Management @@ -123,6 +123,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 3.1.1 | Model Detection | RU+WD | `src/openhuman/local_ai/`, `local-model-runtime.spec.ts` | ✅ | | | 3.1.2 | Model Download & Installation | WD | `local-model-runtime.spec.ts` | ✅ | | | 3.1.3 | Model Version Handling | RU | `src/openhuman/local_ai/model_ids.rs` | ✅ | | +| 3.1.4 | LM Studio Model Discovery | RU+RI | `src/openhuman/local_ai/service/ollama_admin_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | Uses LM Studio's OpenAI-compatible `/v1/models` surface | ### 3.2 Runtime Execution @@ -131,6 +132,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 3.2.1 | Local Inference Execution | WD | `local-model-runtime.spec.ts` | ✅ | | | 3.2.2 | Resource Handling (CPU/GPU/Memory) | RU | `src/openhuman/local_ai/device.rs` | 🟡 | Detection unit; runtime constraint manual | | 3.2.3 | Runtime Failure Handling | RU+WD | `local-model-runtime.spec.ts` | ✅ | | +| 3.2.4 | LM Studio Chat Completions | RU+RI | `src/openhuman/local_ai/service/public_infer_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | Covers prompt/chat success and non-success status errors | ### 3.3 Runtime Configuration @@ -157,6 +159,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 3.3.3.1 | Save RAM Settings | VU | _missing_ | ❌ | Settings slice | | 3.3.3.2 | Apply on Restart | WD | `local-model-runtime.spec.ts` | 🟡 | Restart not exercised | | 3.3.3.3 | Reset to Default | VU | _missing_ | ❌ | | +| 3.3.3.4 | Provider Selection Persistence | RU+RI+VU | `src/openhuman/config/ops_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/utils/tauriCommands/config.test.ts` | ✅ | Covers `lm_studio` normalization and config round-trip | --- diff --git a/docs/agent-workflows/codex-pr-checklist.md b/docs/agent-workflows/codex-pr-checklist.md index ffb1b0ec2..d0621ff20 100644 --- a/docs/agent-workflows/codex-pr-checklist.md +++ b/docs/agent-workflows/codex-pr-checklist.md @@ -20,7 +20,7 @@ git status --porcelain git branch --show-current git remote -v test -f AGENTS.md -test -f gitbooks/developing/frontend/README.md +test -f gitbooks/developing/README.md test -f Cargo.toml test -f app/package.json ``` diff --git a/gitbooks/README.md b/gitbooks/README.md index fd52bd231..b0c04e08d 100644 --- a/gitbooks/README.md +++ b/gitbooks/README.md @@ -20,7 +20,7 @@ OpenHuman solves this with a stack that's calmly, deliberately different: * **An** [**Obsidian-style wiki**](features/obsidian-wiki/) **on top of it.** The same chunks the agent reasons over land as `.md` files in a vault you can open in [Obsidian](https://obsidian.md), browse, edit, and link by hand. Inspired by [Karpathy's obsidian-wiki workflow](https://x.com/karpathy/status/2039805659525644595). You can't trust a memory you can't read. * [**118+ third-party integrations**](features/integrations/README.md)**.** One-click OAuth into Gmail, GitHub, Slack, Notion, Stripe, Calendar, Drive, Linear, Jira and more - no API keys to wire by hand, no plugin marketplace to navigate. * [**Auto-fetch**](features/obsidian-wiki/auto-fetch.md)**.** Every twenty minutes, OpenHuman pulls fresh data from every active connection and folds it into the Memory Tree without you asking, so the agent already has tomorrow's context this morning. -* **An agent built for big data.** [Smart token compression (TokenJuice)](features/token-compression.md) compacts verbose tool output before it ever enters the model's context, so sweeping through your last six months of email costs single-digit dollars. [Automatic model routing](features/model-routing/) sends each task to the right model - `hint:reasoning` to a frontier model, `hint:fast` to a cheap one, vision to vision - all under one subscription. Optional [local AI via Ollama](features/model-routing/local-ai.md) keeps embeddings and summarization on-device. +* **An agent built for big data.** [Smart token compression (TokenJuice)](features/token-compression.md) compacts verbose tool output before it ever enters the model's context, so sweeping through your last six months of email costs single-digit dollars. [Automatic model routing](features/model-routing/) sends each task to the right model - `hint:reasoning` to a frontier model, `hint:fast` to a cheap one, vision to vision - all under one subscription. Optional [local AI via Ollama or LM Studio](features/model-routing/local-ai.md) keeps supported workloads on-device. * [**Batteries included**](features/native-tools/)**.** A complete agent toolbelt is wired in by default: [web search](features/native-tools/web-search.md), a [web-fetch scraper](features/native-tools/web-scraper.md), a full [coder toolset](features/native-tools/coder.md) (filesystem, git, lint, test, grep), [browser & computer control](features/native-tools/browser-and-computer.md), [cron & scheduling](features/native-tools/cron.md), [memory tools](features/native-tools/memory-tools.md), [agent coordination](features/native-tools/agent-coordination.md) for spawning sub-agents, and [native voice](features/native-tools/voice.md) - STT in, TTS out, mascot lip-sync, and a live Google Meet agent that joins meetings, transcribes them into your Memory Tree, and can speak back into the call. No "install a plugin to read files" friction. * **Simple, UI-first.** A clean desktop experience and short onboarding paths take you from install to a working agent in a few clicks - no config-first setup, no terminal required. The agent has [a face](features/mascot.md): a desktop mascot that speaks, reacts to its surroundings, joins your Google Meets as a real participant, remembers you across weeks, and keeps thinking in the background even when you've stopped typing. diff --git a/gitbooks/features/model-routing/local-ai.md b/gitbooks/features/model-routing/local-ai.md index 3c722f582..08d742523 100644 --- a/gitbooks/features/model-routing/local-ai.md +++ b/gitbooks/features/model-routing/local-ai.md @@ -1,6 +1,6 @@ --- description: >- - Optional, opt-in local AI via Ollama. Powers memory embeddings, summary-tree + Optional, opt-in local AI via Ollama or LM Studio. Powers memory embeddings, summary-tree building, and background loops on-device. Chat / vision / voice are cloud. icon: microchip --- @@ -9,7 +9,7 @@ icon: microchip OpenHuman can run a local model on your machine for the workloads where keeping data on-device matters most: **memory embeddings, summary-tree building, and background reasoning loops**. It is **opt-in** and ships **off** by default. -This is a deliberate scoping. The previous design tried to put chat, vision, STT and TTS all on-device with Gemma 3, and the result was a heavy, hardware-sensitive footprint that fought with what the rest of the product needed to be. Today, the things that benefit most from being local (recurring, low-latency, privacy-sensitive memory work) run local; the things that benefit most from frontier models (chat, reasoning, vision) stay cloud. +This is a deliberate scoping. The previous design tried to put chat, vision, STT and TTS all on-device with Gemma 3, and the result was a heavy, hardware-sensitive footprint that fought with what the rest of the product needed to be. Today, the things that benefit most from being local (recurring, low-latency, privacy-sensitive memory work) run local; the things that benefit most from frontier models (default chat, reasoning, vision) stay cloud. ## What runs local when you turn it on @@ -37,12 +37,19 @@ For **lightweight or medium chat hints** (`hint:reaction`, `hint:classify`, `hin ## How it works -Under the hood, OpenHuman uses [Ollama](https://ollama.com) and talks to it over Ollama's OpenAI-compatible `/v1` endpoint. That means: +Under the hood, OpenHuman supports two local provider paths: + +* [Ollama](https://ollama.com), used for bundled model lifecycle, embeddings, and the existing model-asset flow. +* [LM Studio](https://lmstudio.ai), used through its local OpenAI-compatible server for chat-style local inference. + +For Ollama, OpenHuman talks to its OpenAI-compatible `/v1` endpoint where possible. That means: * The `OpenAiCompatibleProvider` (`src/openhuman/providers/compatible.rs`) wraps Ollama exactly the way it wraps a remote OpenAI-style provider. No special-case code path. * The provider router creates a _health-gated_ local provider on startup. If Ollama is not reachable, requests transparently fall back to the remote provider, no broken state. * Models are pulled on demand by Ollama and cached in its own store. OpenHuman doesn't ship the weights itself. +For LM Studio, set `local_ai.provider = "lm_studio"` and ensure LM Studio's local server is running. OpenHuman defaults to `http://localhost:1234/v1`, probes `GET /v1/models`, and sends chat requests to `POST /v1/chat/completions`. You can override the endpoint with `local_ai.base_url`, `OPENHUMAN_LM_STUDIO_BASE_URL`, or `LM_STUDIO_BASE_URL`. + ## Opting in Local AI is gated by two flags in the core config (`src/openhuman/config/schema/local_ai.rs`): @@ -51,6 +58,8 @@ Local AI is gated by two flags in the core config (`src/openhuman/config/schema/ | ------------------------------------ | ------- | ------------------------------------------------------------------- | | `local_ai.runtime_enabled` | `false` | Master switch. `false` ⇒ no local provider is created at all. | | `local_ai.opt_in_confirmed` | `false` | Explicit opt-in marker. Bootstrap forces `false` unless you re-opt. | +| `local_ai.provider` | `ollama` | Local provider: `ollama` or `lm_studio`. | +| `local_ai.base_url` | unset | Optional provider URL. LM Studio defaults to `http://localhost:1234/v1`. | | `local_ai.usage.embeddings` | `false` | Use local for memory embeddings. | | `local_ai.usage.heartbeat` | `false` | Use local for the heartbeat loop. | | `local_ai.usage.learning_reflection` | `false` | Use local for learning passes. | @@ -70,11 +79,18 @@ It is **not** worth turning on if you only have a few sources connected, the clo ## What you'll need -* [**Ollama**](https://ollama.com) installed and running locally. +* [**Ollama**](https://ollama.com) installed and running locally, or [**LM Studio**](https://lmstudio.ai) with the local server enabled. * Enough disk for the models (`gemma3:1b-it-qat` \~700 MB, `all-minilm:latest` \~23 MB). * Enough RAM to keep the model resident (8 GB+ recommended, 16 GB+ ideal). -OpenHuman handles the rest: lifecycle (`src/openhuman/local_ai/service.rs`), API client (`ollama_api.rs`), health checks, and graceful fallback to remote when Ollama disappears. +OpenHuman handles the rest: lifecycle (`src/openhuman/local_ai/service/`), API clients (`ollama_api.rs`, `lm_studio_api.rs`), health checks, and graceful fallback to remote when the local provider disappears. + +### LM Studio troubleshooting + +* Confirm the LM Studio local server is enabled and reachable at `http://localhost:1234/v1`. +* Load the selected model in LM Studio before calling OpenHuman. Diagnostics report `load_lm_studio_model` when the configured `local_ai.chat_model_id` is not present in `/v1/models`. +* If LM Studio uses a different port, set `local_ai.base_url` or `OPENHUMAN_LM_STUDIO_BASE_URL`. +* LM Studio model downloads are managed inside LM Studio. OpenHuman will not pull LM Studio models from the local asset-download controls. ## See also diff --git a/scripts/codex-pr-preflight.mjs b/scripts/codex-pr-preflight.mjs index 63580c8de..3d5a0a851 100755 --- a/scripts/codex-pr-preflight.mjs +++ b/scripts/codex-pr-preflight.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execSync } from 'node:child_process'; -const REQUIRED_FILES = ['AGENTS.md', 'docs/src/README.md', 'Cargo.toml', 'app/package.json']; +const REQUIRED_FILES = ['AGENTS.md', 'gitbooks/developing/README.md', 'Cargo.toml', 'app/package.json']; const APP_PATTERNS = [/^app\//, /^docs\//]; const ROOT_RUST_PATTERNS = [/^src\//, /^tests\//, /^Cargo\.toml$/, /^Cargo\.lock$/]; const TAURI_PATTERNS = [/^app\/src-tauri\//]; @@ -16,6 +16,19 @@ function runGit(command, repoRoot) { return execSync(command, { cwd: repoRoot, encoding: 'utf8' }).trim(); } +function currentRepoRoot() { + const physical = process.cwd(); + const logical = process.env.PWD; + if (logical && path.resolve(logical) !== path.resolve(physical)) { + try { + if (fs.realpathSync(logical) === physical) return logical; + } catch { + // Fall back to Node's physical cwd below. + } + } + return physical; +} + function parseArgs(argv) { return { lightweight: argv.includes('--lightweight'), @@ -57,7 +70,7 @@ function recommendations(changedFiles, lightweight) { function main() { const options = parseArgs(process.argv.slice(2)); - const repoRoot = process.cwd(); + const repoRoot = currentRepoRoot(); const checks = []; checks.push(runCheck('working directory exists', fs.existsSync(repoRoot), repoRoot)); diff --git a/scripts/test-codex-pr-preflight.mjs b/scripts/test-codex-pr-preflight.mjs index 09ead97d4..46a38b176 100755 --- a/scripts/test-codex-pr-preflight.mjs +++ b/scripts/test-codex-pr-preflight.mjs @@ -10,10 +10,10 @@ function run(cmd, cwd) { function makeRepo(branchName) { const dir = mkdtempSync(path.join(tmpdir(), 'codex-preflight-')); - mkdirSync(path.join(dir, 'docs/src'), { recursive: true }); + mkdirSync(path.join(dir, 'gitbooks/developing'), { recursive: true }); mkdirSync(path.join(dir, 'app'), { recursive: true }); writeFileSync(path.join(dir, 'AGENTS.md'), '# test\n'); - writeFileSync(path.join(dir, 'docs/src/README.md'), 'ok\n'); + writeFileSync(path.join(dir, 'gitbooks/developing/README.md'), 'ok\n'); writeFileSync(path.join(dir, 'Cargo.toml'), '[package]\nname="x"\nversion="0.1.0"\n'); writeFileSync(path.join(dir, 'app/package.json'), '{"name":"x"}\n'); run('git init', dir); diff --git a/src/api/config.rs b/src/api/config.rs index 77b1d4a5b..16cd758cb 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -145,6 +145,46 @@ pub fn looks_like_local_ai_endpoint(url: &str) -> bool { port_signals_llm || path_signals_llm } +fn looks_like_openhuman_backend_endpoint(url: &str) -> bool { + let trimmed = url.trim(); + let redacted_url = redact_url_for_log(trimmed); + let parsed = match url::Url::parse(trimmed) { + Ok(parsed) => { + tracing::trace!( + api_url = %redacted_url, + "[api/config] parsed api_url while checking OpenHuman backend classification" + ); + parsed + } + Err(error) => { + tracing::trace!( + api_url = %redacted_url, + error = %error, + "[api/config] api_url parse failed while checking OpenHuman backend classification" + ); + return false; + } + }; + let Some(host) = parsed.host_str().map(str::to_ascii_lowercase) else { + tracing::trace!( + api_url = %redacted_url, + "[api/config] api_url has no host; not classified as OpenHuman backend" + ); + return false; + }; + let is_openhuman_backend = matches!( + host.as_str(), + "api.tinyhumans.ai" | "staging-api.tinyhumans.ai" + ); + tracing::debug!( + api_url = %redacted_url, + host = %host, + is_openhuman_backend, + "[api/config] OpenHuman backend classification complete" + ); + is_openhuman_backend +} + /// Resolves the API base URL for **all hosted-backend calls** (billing, /// team, referral, webhooks, credentials, channels, voice, socket, /// app_state, integrations, core/jsonrpc, etc.). @@ -161,11 +201,30 @@ pub fn looks_like_local_ai_endpoint(url: &str) -> bool { /// can see the diagnostic in their core sidecar logs. pub fn effective_backend_api_url(api_url: &Option) -> String { if let Some(u) = api_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) { - if looks_like_local_ai_endpoint(u) { + let redacted_url = redact_url_for_log(u); + let is_local_ai = looks_like_local_ai_endpoint(u); + let is_openhuman_backend = looks_like_openhuman_backend_endpoint(u); + tracing::debug!( + api_url = %redacted_url, + is_local_ai, + is_openhuman_backend, + "[api/config] evaluating backend api_url override" + ); + if is_local_ai && !is_openhuman_backend { + tracing::debug!( + api_url = %redacted_url, + "[api/config] backend api_url override classified as local AI; falling back to backend default chain" + ); warn_backend_url_fallback_once(u); // Fall through to env / default — do NOT use the user override. } else { - return normalize_backend_api_base_url(u); + let normalized = normalize_backend_api_base_url(u); + tracing::trace!( + api_url = %redacted_url, + normalized_api_url = %redact_url_for_log(&normalized), + "[api/config] using configured backend api_url override" + ); + return normalized; } } if let Some(env_url) = api_base_from_env() { @@ -205,7 +264,7 @@ fn warn_backend_url_fallback_once(local_url: &str) { static WARNED: Once = Once::new(); WARNED.call_once(|| { tracing::warn!( - local_url = %local_url, + local_url = %redact_url_for_log(local_url), "[api/config] config.api_url looks like a local-AI endpoint; \ integrations base will fall back to env/default backend so \ /agent-integrations/* requests don't 404 against your local LLM" @@ -213,6 +272,24 @@ fn warn_backend_url_fallback_once(local_url: &str) { }); } +fn redact_url_for_log(raw: &str) -> String { + let trimmed = raw.trim(); + // Attempt bare-host parsing (e.g. "localhost:1234") before giving up so + // that non-scheme URLs are still redacted rather than returned verbatim. + let parsed = + url::Url::parse(trimmed).or_else(|_| url::Url::parse(&format!("http://{trimmed}"))); + let Ok(mut parsed) = parsed else { + return trimmed.to_string(); + }; + if !parsed.username().is_empty() { + let _ = parsed.set_username("redacted"); + } + if parsed.password().is_some() { + let _ = parsed.set_password(Some("redacted")); + } + parsed.to_string().trim_end_matches('/').to_string() +} + /// Trim and strip trailing slashes so paths join consistently. pub fn normalize_api_base_url(url: &str) -> String { url.trim().trim_end_matches('/').to_string() @@ -385,6 +462,12 @@ mod tests { } } + fn fallback_backend_base_for_current_build() -> String { + api_base_from_env().unwrap_or_else(|| { + default_api_base_url_for_env(app_env_from_env().as_deref()).to_string() + }) + } + impl Drop for EnvSnapshot { fn drop(&mut self) { for (key, value) in self.vars.iter() { @@ -620,6 +703,22 @@ mod tests { )); } + #[test] + fn openhuman_backend_endpoint_detection_accepts_hosted_api_paths() { + assert!(looks_like_openhuman_backend_endpoint( + "https://api.tinyhumans.ai/openai/v1/chat/completions" + )); + assert!(looks_like_openhuman_backend_endpoint( + "https://staging-api.tinyhumans.ai/openai/v1/chat/completions" + )); + assert!(!looks_like_openhuman_backend_endpoint( + "https://openrouter.ai/api/v1/chat/completions" + )); + assert!(!looks_like_openhuman_backend_endpoint( + "http://localhost:1234/v1/chat/completions" + )); + } + #[test] fn looks_like_local_ai_rejects_substring_path_false_positives() { // graycyrus review of #1630: an earlier version used @@ -761,32 +860,33 @@ mod tests { fn integrations_url_handles_llm_endpoint_overrides() { let _guard = env_lock(); let _env = EnvSnapshot::clear_backend_env(); + let fallback_backend = fallback_backend_base_for_current_build(); struct Case { api_url: &'static str, - expected: &'static str, + expected: String, } let cases = [ Case { api_url: "https://api.tinyhumans.ai/openai/v1/chat/completions", - expected: "https://api.tinyhumans.ai", + expected: "https://api.tinyhumans.ai".to_string(), }, Case { api_url: "http://localhost:11434/v1/chat/completions", - expected: DEFAULT_API_BASE_URL, + expected: fallback_backend.clone(), }, Case { api_url: "https://api.tinyhumans.ai", - expected: "https://api.tinyhumans.ai", + expected: "https://api.tinyhumans.ai".to_string(), }, Case { api_url: "https://api.tinyhumans.ai/openai/v1/", - expected: "https://api.tinyhumans.ai", + expected: "https://api.tinyhumans.ai".to_string(), }, Case { api_url: "https://openrouter.ai/api/v1/chat/completions", - expected: DEFAULT_API_BASE_URL, + expected: fallback_backend, }, ]; @@ -803,37 +903,12 @@ mod tests { #[test] fn integrations_url_falls_back_to_default_when_override_is_local_ai() { let _guard = env_lock(); - // Clear env so we deterministically hit the default branch. - let prev_backend = std::env::var("BACKEND_URL").ok(); - let prev_vite_backend = std::env::var("VITE_BACKEND_URL").ok(); - let prev_app_env = std::env::var(APP_ENV_VAR).ok(); - let prev_vite_app_env = std::env::var(VITE_APP_ENV_VAR).ok(); - std::env::remove_var("BACKEND_URL"); - std::env::remove_var("VITE_BACKEND_URL"); - std::env::remove_var(APP_ENV_VAR); - std::env::remove_var(VITE_APP_ENV_VAR); + let _env = EnvSnapshot::clear_backend_env(); + let expected = fallback_backend_base_for_current_build(); let result = effective_backend_api_url(&Some("http://127.0.0.1:11434/v1".to_string())); - // Restore env before asserting so a failing assert doesn't leak. - match prev_backend { - Some(v) => std::env::set_var("BACKEND_URL", v), - None => std::env::remove_var("BACKEND_URL"), - } - match prev_vite_backend { - Some(v) => std::env::set_var("VITE_BACKEND_URL", v), - None => std::env::remove_var("VITE_BACKEND_URL"), - } - match prev_app_env { - Some(v) => std::env::set_var(APP_ENV_VAR, v), - None => std::env::remove_var(APP_ENV_VAR), - } - match prev_vite_app_env { - Some(v) => std::env::set_var(VITE_APP_ENV_VAR, v), - None => std::env::remove_var(VITE_APP_ENV_VAR), - } - - assert_eq!(result, DEFAULT_API_BASE_URL); + assert_eq!(result, expected); } #[test] @@ -865,36 +940,11 @@ mod tests { #[test] fn integrations_url_matches_effective_api_url_without_override() { let _guard = env_lock(); - // No override, no env → both helpers must agree. - let prev_backend = std::env::var("BACKEND_URL").ok(); - let prev_vite_backend = std::env::var("VITE_BACKEND_URL").ok(); - let prev_app_env = std::env::var(APP_ENV_VAR).ok(); - let prev_vite_app_env = std::env::var(VITE_APP_ENV_VAR).ok(); - std::env::remove_var("BACKEND_URL"); - std::env::remove_var("VITE_BACKEND_URL"); - std::env::remove_var(APP_ENV_VAR); - std::env::remove_var(VITE_APP_ENV_VAR); + let _env = EnvSnapshot::clear_backend_env(); let integrations = effective_backend_api_url(&None); let api = effective_api_url(&None); - match prev_backend { - Some(v) => std::env::set_var("BACKEND_URL", v), - None => std::env::remove_var("BACKEND_URL"), - } - match prev_vite_backend { - Some(v) => std::env::set_var("VITE_BACKEND_URL", v), - None => std::env::remove_var("VITE_BACKEND_URL"), - } - match prev_app_env { - Some(v) => std::env::set_var(APP_ENV_VAR, v), - None => std::env::remove_var(APP_ENV_VAR), - } - match prev_vite_app_env { - Some(v) => std::env::set_var(VITE_APP_ENV_VAR, v), - None => std::env::remove_var(VITE_APP_ENV_VAR), - } - assert_eq!(integrations, api); } } diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index 625408dc0..782a06212 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -457,6 +457,16 @@ const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: MODEL_DOWNLOAD, }, + Capability { + id: "local_ai.configure_provider", + name: "Configure Local Provider", + domain: "local_ai", + category: CapabilityCategory::LocalAI, + description: "Select Ollama or LM Studio as the local model provider and configure the local server endpoint.", + how_to: "Settings > Local AI Model", + status: CapabilityStatus::Beta, + privacy: None, + }, Capability { id: "local_ai.manage_model_assets", name: "Manage Model Assets", diff --git a/src/openhuman/about_app/catalog_tests.rs b/src/openhuman/about_app/catalog_tests.rs index 8c15346c7..c836d4872 100644 --- a/src/openhuman/about_app/catalog_tests.rs +++ b/src/openhuman/about_app/catalog_tests.rs @@ -78,6 +78,7 @@ fn catalog_includes_additional_user_facing_surfaces() { "auth.configure_tool_access", "settings.manage_service", "settings.clear_app_data", + "local_ai.configure_provider", "meet.join_call", "meet_agent.live_loop", "intelligence.mcp_server", diff --git a/src/openhuman/accessibility/automation_state.rs b/src/openhuman/accessibility/automation_state.rs index 98c0fe9f6..595a4c583 100644 --- a/src/openhuman/accessibility/automation_state.rs +++ b/src/openhuman/accessibility/automation_state.rs @@ -46,29 +46,26 @@ pub fn clear() { SYSTEM_EVENTS_DENIED.store(false, Ordering::Relaxed); } +#[cfg(test)] +pub(crate) fn test_lock() -> std::sync::MutexGuard<'static, ()> { + static M: std::sync::Mutex<()> = std::sync::Mutex::new(()); + M.lock().unwrap_or_else(|e| e.into_inner()) +} + #[cfg(test)] mod tests { use super::*; - /// All tests share global state. Run them serially behind a Mutex so - /// concurrent set/clear calls in libtest's parallel scheduler don't - /// produce flaky assertions. The flag itself is process-local so we - /// can't isolate it per-test — best-effort: clear before + after. - fn lock() -> std::sync::MutexGuard<'static, ()> { - static M: std::sync::Mutex<()> = std::sync::Mutex::new(()); - M.lock().unwrap_or_else(|e| e.into_inner()) - } - #[test] fn defaults_to_not_denied() { - let _g = lock(); + let _g = test_lock(); clear(); assert!(!system_events_denied()); } #[test] fn mark_then_observe() { - let _g = lock(); + let _g = test_lock(); clear(); assert!(!system_events_denied()); mark_system_events_denied(); @@ -79,7 +76,7 @@ mod tests { #[test] fn idempotent_mark_and_clear() { - let _g = lock(); + let _g = test_lock(); clear(); mark_system_events_denied(); mark_system_events_denied(); @@ -91,7 +88,7 @@ mod tests { #[test] fn concurrent_mark_and_read() { - let _g = lock(); + let _g = test_lock(); clear(); let producers: Vec<_> = (0..8) .map(|_| std::thread::spawn(mark_system_events_denied)) diff --git a/src/openhuman/accessibility/permissions_tests.rs b/src/openhuman/accessibility/permissions_tests.rs index d8c88d1bf..671cedab3 100644 --- a/src/openhuman/accessibility/permissions_tests.rs +++ b/src/openhuman/accessibility/permissions_tests.rs @@ -156,16 +156,14 @@ fn permission_state_serde_round_trip() { // mechanism used by `autocomplete::start_if_enabled` on re-engagement. mod automation_state_stale_cache { + use crate::openhuman::accessibility::automation_state; use crate::openhuman::accessibility::{ clear_automation_denial, mark_system_events_denied, system_events_denied, }; - use std::sync::Mutex; - - static LOCK: Mutex<()> = Mutex::new(()); #[test] fn fresh_state_is_not_denied() { - let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _g = automation_state::test_lock(); clear_automation_denial(); assert!( !system_events_denied(), @@ -175,7 +173,7 @@ mod automation_state_stale_cache { #[test] fn clear_resets_denial_flag() { - let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _g = automation_state::test_lock(); clear_automation_denial(); mark_system_events_denied(); assert!(system_events_denied(), "should be denied after mark"); @@ -188,7 +186,7 @@ mod automation_state_stale_cache { #[test] fn denied_flag_does_not_persist_through_clear() { - let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _g = automation_state::test_lock(); // Simulate: previous session left the flag set. // clear() is called on re-engagement → no stale state carried over. mark_system_events_denied(); diff --git a/src/openhuman/agent/triage/escalation.rs b/src/openhuman/agent/triage/escalation.rs index 8f342e8c0..5efc92e90 100644 --- a/src/openhuman/agent/triage/escalation.rs +++ b/src/openhuman/agent/triage/escalation.rs @@ -205,12 +205,26 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result = tokio::sync::Mutex::const_new(()); + + struct TestEventsGuard(tokio::sync::MutexGuard<'static, ()>); + + impl Drop for TestEventsGuard { + fn drop(&mut self) { + events::clear_test_events(); + } + } + + async fn test_events_guard() -> TestEventsGuard { + let guard = TEST_EVENTS_LOCK.lock().await; + events::clear_test_events(); + TestEventsGuard(guard) + } fn envelope(external_id: &str) -> TriggerEnvelope { TriggerEnvelope::from_composio( @@ -250,68 +264,30 @@ mod tests { } } - fn subscribe_probe() -> broadcast::Receiver { - global().unwrap().raw_receiver() - } - - fn trigger_external_id(event: &DomainEvent) -> Option<&str> { - match event { - DomainEvent::TriggerEvaluated { external_id, .. } - | DomainEvent::TriggerEscalated { external_id, .. } - | DomainEvent::TriggerEscalationFailed { external_id, .. } => Some(external_id), - _ => None, - } - } - async fn collect_trigger_events_until( - mut rx: broadcast::Receiver, external_id: &str, expected: impl Fn(&[DomainEvent]) -> bool, ) -> Vec { let external_id = external_id.to_string(); - let mut captured = timeout(Duration::from_secs(5), async { - let mut captured = Vec::new(); + timeout(Duration::from_secs(5), async { loop { + let captured = events::test_events_for_external_id(&external_id); if expected(&captured) { return captured; } - match rx.recv().await { - Ok(event) => { - if trigger_external_id(&event) == Some(external_id.as_str()) { - captured.push(event); - } - } - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => { - panic!("probe channel should stay open") - } - } + sleep(Duration::from_millis(10)).await; } }) .await - .expect("expected triage event should arrive"); - - while let Ok(result) = timeout(Duration::from_millis(50), rx.recv()).await { - match result { - Ok(event) => { - if trigger_external_id(&event) == Some(external_id.as_str()) { - captured.push(event); - } - } - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, - } - } - - captured + .expect("expected triage event should arrive") } #[tokio::test] async fn apply_decision_drop_only_publishes_evaluated() { + let _events_guard = test_events_guard().await; let envelope = envelope("esc-drop"); let _ = init_global(32); - let rx = subscribe_probe(); - let collect = tokio::spawn(collect_trigger_events_until(rx, "esc-drop", |events| { + let collect = tokio::spawn(collect_trigger_events_until("esc-drop", |events| { events.iter().any(|event| { matches!( event, @@ -323,7 +299,6 @@ mod tests { ) }) })); - yield_now().await; apply_decision(run(TriageAction::Drop), &envelope) .await @@ -348,10 +323,10 @@ mod tests { #[tokio::test] async fn apply_decision_acknowledge_only_publishes_evaluated() { + let _events_guard = test_events_guard().await; let envelope = envelope("esc-ack"); let _ = init_global(32); - let rx = subscribe_probe(); - let collect = tokio::spawn(collect_trigger_events_until(rx, "esc-ack", |events| { + let collect = tokio::spawn(collect_trigger_events_until("esc-ack", |events| { events.iter().any(|event| { matches!( event, @@ -363,7 +338,6 @@ mod tests { ) }) })); - yield_now().await; apply_decision(run(TriageAction::Acknowledge), &envelope) .await @@ -388,33 +362,28 @@ mod tests { #[tokio::test] async fn apply_decision_react_failure_publishes_failed_event() { + let _events_guard = test_events_guard().await; let envelope = envelope("esc-react-fail"); let _ = init_global(32); let _ = AgentDefinitionRegistry::init_global_builtins(); - let rx = subscribe_probe(); - let collect = tokio::spawn(collect_trigger_events_until( - rx, - "esc-react-fail", - |events| { - events.iter().any(|event| { - matches!( - event, - DomainEvent::TriggerEvaluated { - decision, - external_id, - .. - } if decision == "react" && external_id == "esc-react-fail" - ) - }) && events.iter().any(|event| { - matches!( - event, - DomainEvent::TriggerEscalationFailed { external_id, reason, .. } - if external_id == "esc-react-fail" && reason.contains("missing-agent") - ) - }) - }, - )); - yield_now().await; + let collect = tokio::spawn(collect_trigger_events_until("esc-react-fail", |events| { + events.iter().any(|event| { + matches!( + event, + DomainEvent::TriggerEvaluated { + decision, + external_id, + .. + } if decision == "react" && external_id == "esc-react-fail" + ) + }) && events.iter().any(|event| { + matches!( + event, + DomainEvent::TriggerEscalationFailed { external_id, reason, .. } + if external_id == "esc-react-fail" && reason.contains("missing-agent") + ) + }) + })); let err = apply_decision( run_with_target(TriageAction::React, "missing-agent", "handle this"), @@ -442,12 +411,11 @@ mod tests { #[tokio::test] async fn apply_decision_escalate_failure_publishes_failed_event() { + let _events_guard = test_events_guard().await; let envelope = envelope("esc-escalate-fail"); let _ = init_global(32); let _ = AgentDefinitionRegistry::init_global_builtins(); - let rx = subscribe_probe(); let collect = tokio::spawn(collect_trigger_events_until( - rx, "esc-escalate-fail", |events| { events.iter().any(|event| { @@ -466,7 +434,6 @@ mod tests { )) }, )); - yield_now().await; let err = apply_decision( run_with_target(TriageAction::Escalate, "missing-agent", "escalate this"), diff --git a/src/openhuman/agent/triage/events.rs b/src/openhuman/agent/triage/events.rs index 8a77c1d64..7a93a84ae 100644 --- a/src/openhuman/agent/triage/events.rs +++ b/src/openhuman/agent/triage/events.rs @@ -11,6 +11,53 @@ use crate::core::event_bus::{publish_global, DomainEvent}; use super::envelope::TriggerEnvelope; +#[cfg(test)] +static TEST_EVENTS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn record_test_event(event: &DomainEvent) { + TEST_EVENTS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(event.clone()); +} + +#[cfg(test)] +pub(crate) fn test_events_for_external_id(external_id: &str) -> Vec { + TEST_EVENTS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .filter(|event| match event { + DomainEvent::TriggerEvaluated { + external_id: event_external_id, + .. + } + | DomainEvent::TriggerEscalated { + external_id: event_external_id, + .. + } + | DomainEvent::TriggerEscalationFailed { + external_id: event_external_id, + .. + } => event_external_id == external_id, + _ => false, + }) + .cloned() + .collect() +} + +#[cfg(test)] +pub(crate) fn clear_test_events() { + TEST_EVENTS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clear(); +} + +#[cfg(not(test))] +fn record_test_event(_event: &DomainEvent) {} + /// Publish [`DomainEvent::TriggerEvaluated`] for the given envelope. /// Fires for *every* triage run, regardless of action. pub fn publish_evaluated( @@ -19,36 +66,42 @@ pub fn publish_evaluated( used_local: bool, latency_ms: u64, ) { - publish_global(DomainEvent::TriggerEvaluated { + let event = DomainEvent::TriggerEvaluated { source: envelope.source.slug().to_string(), external_id: envelope.external_id.clone(), display_label: envelope.display_label.clone(), decision: decision.to_string(), used_local, latency_ms, - }); + }; + record_test_event(&event); + publish_global(event); } /// Publish [`DomainEvent::TriggerEscalated`] — fired only on /// `react`/`escalate`, *in addition* to `TriggerEvaluated`. pub fn publish_escalated(envelope: &TriggerEnvelope, target_agent: &str) { - publish_global(DomainEvent::TriggerEscalated { + let event = DomainEvent::TriggerEscalated { source: envelope.source.slug().to_string(), external_id: envelope.external_id.clone(), display_label: envelope.display_label.clone(), target_agent: target_agent.to_string(), - }); + }; + record_test_event(&event); + publish_global(event); } /// Publish [`DomainEvent::TriggerEscalationFailed`] — fired when the /// whole pipeline gave up (both local and remote failed, or the /// classifier reply couldn't be parsed after a retry). pub fn publish_failed(envelope: &TriggerEnvelope, reason: &str) { - publish_global(DomainEvent::TriggerEscalationFailed { + let event = DomainEvent::TriggerEscalationFailed { source: envelope.source.slug().to_string(), external_id: envelope.external_id.clone(), reason: reason.to_string(), - }); + }; + record_test_event(&event); + publish_global(event); } #[cfg(test)] diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 9cf8e16ae..4a5f18f25 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -236,6 +236,11 @@ pub struct MeetSettingsPatch { #[derive(Debug, Clone, Default)] pub struct LocalAiSettingsPatch { pub runtime_enabled: Option, + pub opt_in_confirmed: Option, + pub provider: Option, + pub base_url: Option, + pub model_id: Option, + pub chat_model_id: Option, pub usage_embeddings: Option, pub usage_heartbeat: Option, pub usage_learning_reflection: Option, @@ -564,6 +569,26 @@ pub async fn apply_local_ai_settings( if let Some(v) = update.runtime_enabled { config.local_ai.runtime_enabled = v; } + if let Some(v) = update.opt_in_confirmed { + config.local_ai.opt_in_confirmed = v; + } + if let Some(provider) = update.provider { + config.local_ai.provider = + crate::openhuman::local_ai::provider::normalize_provider(&provider); + } + if let Some(base_url) = update.base_url { + config.local_ai.base_url = if base_url.trim().is_empty() { + None + } else { + Some(base_url.trim().to_string()) + }; + } + if let Some(model_id) = update.model_id { + config.local_ai.model_id = model_id.trim().to_string(); + } + if let Some(chat_model_id) = update.chat_model_id { + config.local_ai.chat_model_id = chat_model_id.trim().to_string(); + } if let Some(v) = update.usage_embeddings { config.local_ai.usage.embeddings = v; } diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 8fc87e614..c43513418 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -437,6 +437,62 @@ async fn apply_browser_settings_updates_enabled_flag() { assert!(cfg.browser.enabled); } +#[tokio::test] +async fn apply_local_ai_settings_updates_lm_studio_provider_fields() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.local_ai.model_id = "old-default".into(); + cfg.local_ai.chat_model_id = "old-chat".into(); + + let patch = LocalAiSettingsPatch { + runtime_enabled: Some(true), + opt_in_confirmed: Some(true), + provider: Some("lm-studio".into()), + base_url: Some(" http://localhost:1234/v1/ ".into()), + model_id: Some(" local-default ".into()), + chat_model_id: Some(" local-chat ".into()), + usage_embeddings: Some(true), + usage_heartbeat: Some(true), + usage_learning_reflection: Some(false), + usage_subconscious: Some(true), + }; + + let outcome = apply_local_ai_settings(&mut cfg, patch) + .await + .expect("apply local ai"); + + assert!(cfg.local_ai.runtime_enabled); + assert!(cfg.local_ai.opt_in_confirmed); + assert_eq!(cfg.local_ai.provider, "lm_studio"); + assert_eq!( + cfg.local_ai.base_url.as_deref(), + Some("http://localhost:1234/v1/") + ); + assert_eq!(cfg.local_ai.model_id, "local-default"); + assert_eq!(cfg.local_ai.chat_model_id, "local-chat"); + assert!(cfg.local_ai.usage.embeddings); + assert!(cfg.local_ai.usage.heartbeat); + assert!(!cfg.local_ai.usage.learning_reflection); + assert!(cfg.local_ai.usage.subconscious); + assert_eq!(outcome.value["config"]["local_ai"]["provider"], "lm_studio"); + + let clear_and_fallback = LocalAiSettingsPatch { + provider: Some("unknown-provider".into()), + base_url: Some(" ".into()), + model_id: Some(" ".into()), + chat_model_id: Some("".into()), + ..LocalAiSettingsPatch::default() + }; + apply_local_ai_settings(&mut cfg, clear_and_fallback) + .await + .expect("clear local ai"); + + assert_eq!(cfg.local_ai.provider, "ollama"); + assert!(cfg.local_ai.base_url.is_none()); + assert_eq!(cfg.local_ai.model_id, ""); + assert_eq!(cfg.local_ai.chat_model_id, ""); +} + #[tokio::test] async fn apply_analytics_settings_updates_enabled() { let tmp = tempdir().unwrap(); diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 060a6072e..6c624ea0f 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -623,9 +623,22 @@ async fn sync_directory(_path: &Path) -> Result<()> { impl Config { pub async fn load_or_init() -> Result { let (default_openhuman_dir, default_workspace_dir) = default_config_and_workspace_dirs()?; + Self::load_or_init_with_env_lookup( + &default_openhuman_dir, + &default_workspace_dir, + &ProcessEnv, + ) + .await + } + async fn load_or_init_with_env_lookup( + default_openhuman_dir: &Path, + default_workspace_dir: &Path, + env: &(dyn EnvLookup + Send + Sync), + ) -> Result { let (openhuman_dir, workspace_dir, resolution_source) = - resolve_runtime_config_dirs(&default_openhuman_dir, &default_workspace_dir).await?; + resolve_runtime_config_dirs_with(default_openhuman_dir, default_workspace_dir, env) + .await?; let config_path = openhuman_dir.join("config.toml"); diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index 3cf5d72be..685de0bd2 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -1142,6 +1142,13 @@ fn env_lock() -> std::sync::MutexGuard<'static, ()> { ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) } +async fn load_or_init_for_workspace(root: &std::path::Path) -> Config { + let env = MapEnv::default().with("OPENHUMAN_WORKSPACE", root.to_str().unwrap()); + Config::load_or_init_with_env_lookup(root, &root.join("workspace"), &env) + .await + .unwrap() +} + #[tokio::test] async fn load_or_init_recovers_from_backup_when_config_corrupted() { let _g = env_lock(); @@ -1160,11 +1167,7 @@ default_temperature = 0.7 ) .await; - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", root.to_str().unwrap()); - } - - let config = Config::load_or_init().await.unwrap(); + let config = load_or_init_for_workspace(root).await; assert_eq!( config.default_model.as_deref(), @@ -1186,10 +1189,6 @@ default_temperature = 0.7 bak_contents.contains("gpt-recovery-test"), "backup must not be overwritten by corrupted config during save: {bak_contents}" ); - - unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - } } #[tokio::test] @@ -1204,11 +1203,7 @@ async fn load_or_init_falls_back_to_defaults_when_backup_also_corrupted() { write_file(&config_path, CORRUPTED_TOML).await; write_file(&backup_path, CORRUPTED_TOML).await; - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", root.to_str().unwrap()); - } - - let config = Config::load_or_init().await.unwrap(); + let config = load_or_init_for_workspace(root).await; // Config::default() sets default_model = Some("reasoning-v1"). assert_eq!( @@ -1231,10 +1226,6 @@ async fn load_or_init_falls_back_to_defaults_when_backup_also_corrupted() { tokio::fs::try_exists(&corrupted_path).await.unwrap(), "corrupted primary must be renamed to config.toml.corrupted" ); - - unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - } } #[tokio::test] @@ -1246,11 +1237,7 @@ async fn load_or_init_falls_back_to_defaults_when_no_backup() { let config_path = root.join("config.toml"); write_file(&config_path, CORRUPTED_TOML).await; - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", root.to_str().unwrap()); - } - - let config = Config::load_or_init().await.unwrap(); + let config = load_or_init_for_workspace(root).await; assert_eq!( config.default_model.as_deref(), @@ -1266,10 +1253,6 @@ async fn load_or_init_falls_back_to_defaults_when_no_backup() { tokio::fs::try_exists(&corrupted_path).await.unwrap(), "corrupted primary must be renamed to config.toml.corrupted" ); - - unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - } } #[tokio::test] @@ -1286,21 +1269,13 @@ default_temperature = 0.7 ) .await; - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", root.to_str().unwrap()); - } - - let config = Config::load_or_init().await.unwrap(); + let config = load_or_init_for_workspace(root).await; assert_eq!( config.default_model.as_deref(), Some("gpt-valid"), "valid config must load normally without recovery" ); - - unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - } } #[test] diff --git a/src/openhuman/config/schema/local_ai.rs b/src/openhuman/config/schema/local_ai.rs index 35344a8c4..a1a9c4d31 100644 --- a/src/openhuman/config/schema/local_ai.rs +++ b/src/openhuman/config/schema/local_ai.rs @@ -3,8 +3,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// Per-feature flags controlling which subsystems route through the local -/// Ollama runtime. All default to `false` (use cloud instead). Guarded by +/// Per-feature flags controlling which subsystems route through the selected +/// local runtime. All default to `false` (use cloud instead). Guarded by /// `LocalAiConfig::runtime_enabled` — when that is `false` every helper /// method below returns `false` regardless of these values. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -42,13 +42,17 @@ impl Default for LocalAiUsage { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct LocalAiConfig { - /// Master runtime switch. Defaults to `false` — Ollama is OFF by default. + /// Master runtime switch. Defaults to `false` — local AI is OFF by default. /// Note: the old on-disk field was `enabled`; that key is now unknown to /// serde and will be silently ignored on load (intentional forced reset). #[serde(default = "default_runtime_enabled")] pub runtime_enabled: bool, + /// Local provider identifier. Supported values are `ollama` and + /// `lm_studio`; unknown values normalize to `ollama` at runtime. #[serde(default = "default_provider")] pub provider: String, + /// Optional provider base URL. For LM Studio this defaults to + /// `http://localhost:1234/v1`. #[serde(default)] pub base_url: Option, #[serde(default)] diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index b6e034c6d..3d4dc2ffb 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -89,6 +89,11 @@ struct MeetSettingsUpdate { #[derive(Debug, Deserialize)] struct LocalAiSettingsUpdate { runtime_enabled: Option, + opt_in_confirmed: Option, + provider: Option, + base_url: Option, + model_id: Option, + chat_model_id: Option, usage_embeddings: Option, usage_heartbeat: Option, usage_learning_reflection: Option, @@ -462,8 +467,22 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![ optional_bool( "runtime_enabled", - "Master switch — when false, no subsystem uses the local Ollama runtime.", + "Master switch — when false, no subsystem uses the selected local AI runtime.", ), + optional_bool( + "opt_in_confirmed", + "Explicit local AI opt-in marker required by bootstrap.", + ), + optional_string( + "provider", + "Local provider identifier. Supported values: ollama, lm_studio.", + ), + optional_string( + "base_url", + "Provider base URL. For LM Studio this defaults to http://localhost:1234/v1.", + ), + optional_string("model_id", "Default local chat model identifier."), + optional_string("chat_model_id", "Local chat model identifier."), optional_bool( "usage_embeddings", "Use the local model for embedding generation (when runtime_enabled).", @@ -904,6 +923,11 @@ fn handle_update_local_ai_settings(params: Map) -> ControllerFutu let update = deserialize_params::(params)?; let patch = config_rpc::LocalAiSettingsPatch { runtime_enabled: update.runtime_enabled, + opt_in_confirmed: update.opt_in_confirmed, + provider: update.provider, + base_url: update.base_url, + model_id: update.model_id, + chat_model_id: update.chat_model_id, usage_embeddings: update.usage_embeddings, usage_heartbeat: update.usage_heartbeat, usage_learning_reflection: update.usage_learning_reflection, diff --git a/src/openhuman/config/schemas_tests.rs b/src/openhuman/config/schemas_tests.rs index 56f40fce9..acdbc00da 100644 --- a/src/openhuman/config/schemas_tests.rs +++ b/src/openhuman/config/schemas_tests.rs @@ -33,6 +33,7 @@ fn every_registered_key_resolves_to_non_unknown_schema() { "update_screen_intelligence_settings", "update_runtime_settings", "update_browser_settings", + "update_local_ai_settings", "resolve_api_url", "get_runtime_flags", "set_browser_allow_all", @@ -141,6 +142,32 @@ fn deserialize_params_parses_memory_settings_update() { assert_eq!(out.embedding_dimensions, Some(1536)); } +#[test] +fn deserialize_params_parses_local_ai_settings_update() { + let mut m = Map::new(); + m.insert("runtime_enabled".into(), Value::Bool(true)); + m.insert("opt_in_confirmed".into(), Value::Bool(true)); + m.insert("provider".into(), Value::String("lm_studio".into())); + m.insert( + "base_url".into(), + Value::String("http://localhost:1234/v1".into()), + ); + m.insert("model_id".into(), Value::String("local-default".into())); + m.insert("chat_model_id".into(), Value::String("local-chat".into())); + m.insert("usage_embeddings".into(), Value::Bool(true)); + m.insert("usage_subconscious".into(), Value::Bool(false)); + + let out: LocalAiSettingsUpdate = deserialize_params(m).unwrap(); + assert_eq!(out.runtime_enabled, Some(true)); + assert_eq!(out.opt_in_confirmed, Some(true)); + assert_eq!(out.provider.as_deref(), Some("lm_studio")); + assert_eq!(out.base_url.as_deref(), Some("http://localhost:1234/v1")); + assert_eq!(out.model_id.as_deref(), Some("local-default")); + assert_eq!(out.chat_model_id.as_deref(), Some("local-chat")); + assert_eq!(out.usage_embeddings, Some(true)); + assert_eq!(out.usage_subconscious, Some(false)); +} + #[test] fn deserialize_params_parses_workspace_onboarding_flag_params() { let out: WorkspaceOnboardingFlagParams = deserialize_params(Map::new()).unwrap(); diff --git a/src/openhuman/local_ai/README.md b/src/openhuman/local_ai/README.md index 0008c35b4..2490c5463 100644 --- a/src/openhuman/local_ai/README.md +++ b/src/openhuman/local_ai/README.md @@ -1,10 +1,10 @@ # Local AI -On-device inference stack. Owns the bundled Ollama runtime, whisper.cpp speech-to-text, Piper text-to-speech, sentiment scoring, vision-embedding routing, the model preset / device-profile chooser, asset download + install management, the GIF-decision heuristic, and the per-session `LocalAiService` singleton. Does NOT own remote-provider HTTP transport (`providers/`) or the agent tool loop (`agent/`). +On-device inference stack. Owns the bundled Ollama runtime, LM Studio local-server integration, whisper.cpp speech-to-text, Piper text-to-speech, sentiment scoring, vision-embedding routing, the model preset / device-profile chooser, asset download + install management, the GIF-decision heuristic, and the per-session `LocalAiService` singleton. Does NOT own remote-provider HTTP transport (`providers/`) or the agent tool loop (`agent/`). ## Public surface -- `pub struct LocalAiService` — `service/mod.rs` — singleton holding Ollama / whisper / Piper handles. +- `pub struct LocalAiService` — `service/mod.rs` — singleton holding Ollama / LM Studio / whisper / Piper handles. - `pub fn global(config: &Config) -> Arc` — `core.rs` — singleton accessor. - `pub fn model_artifact_path(config: &Config) -> PathBuf` — `core.rs` — resolve on-disk model path. - `pub struct DeviceProfile` — `device.rs` — RAM / VRAM / CPU classification used for preset selection. @@ -17,9 +17,10 @@ On-device inference stack. Owns the bundled Ollama runtime, whisper.cpp speech-t ## Calls into -- `src/openhuman/config/` — model paths, Ollama URL override, device-profile inputs. +- `src/openhuman/config/` — provider selection, model IDs, local server URL override, device-profile inputs. - `src/openhuman/encryption/` — Tenor / asset keys at rest. - Bundled binaries: Ollama (HTTP `OLLAMA_BASE_URL`), whisper.cpp, Piper. +- LM Studio local server via OpenAI-compatible `GET /v1/models` and `POST /v1/chat/completions`. - HTTP for Tenor GIF search. - Filesystem under `~/.openhuman/local-ai/` for downloaded model artifacts. @@ -38,3 +39,9 @@ On-device inference stack. Owns the bundled Ollama runtime, whisper.cpp speech-t - Unit: `ops_tests.rs`, `schemas_tests.rs`, plus `service/ollama_admin_tests.rs`, `service/public_infer_tests.rs`. - Domain mutex: `LOCAL_AI_TEST_MUTEX` (`mod.rs:4`) serializes tests that mutate the singleton or env vars. - Routing: `agent/triage/routing_tests.rs` covers local-vs-remote escalation. + +## LM Studio + +Set `local_ai.provider = "lm_studio"`, `local_ai.runtime_enabled = true`, and `local_ai.opt_in_confirmed = true`, then run LM Studio's local server with the OpenAI-compatible API enabled. The default base URL is `http://localhost:1234/v1`; override it with `local_ai.base_url`, `OPENHUMAN_LM_STUDIO_BASE_URL`, or `LM_STUDIO_BASE_URL`. + +This first provider slice covers connection validation, model discovery, diagnostics, direct local chat/prompt requests, and intelligent-routing local chat through LM Studio. LM Studio manages its own model downloads and loading; OpenHuman reports missing chat models as actionable status instead of trying to pull them. Vision and embeddings stay on the existing Ollama-specific paths until those provider surfaces are split. diff --git a/src/openhuman/local_ai/lm_studio_api.rs b/src/openhuman/local_ai/lm_studio_api.rs new file mode 100644 index 000000000..03344c7e4 --- /dev/null +++ b/src/openhuman/local_ai/lm_studio_api.rs @@ -0,0 +1,231 @@ +//! LM Studio OpenAI-compatible HTTP types and helpers. +//! +//! LM Studio exposes an OpenAI-compatible API under `http://localhost:1234/v1` +//! by default. This module keeps the wire contract separate from the Ollama +//! native API structs so the two providers can evolve independently. + +use crate::openhuman::config::{Config, LocalAiConfig}; +use serde::{Deserialize, Serialize}; + +pub(crate) const DEFAULT_LM_STUDIO_BASE_URL: &str = "http://localhost:1234/v1"; + +pub(crate) fn lm_studio_base_url(config: &Config) -> String { + lm_studio_base_url_from_local_ai(&config.local_ai) +} + +pub(crate) fn lm_studio_base_url_from_local_ai(local_ai: &LocalAiConfig) -> String { + for (source, candidate) in [ + ( + "OPENHUMAN_LM_STUDIO_BASE_URL", + std::env::var("OPENHUMAN_LM_STUDIO_BASE_URL").ok(), + ), + ( + "LM_STUDIO_BASE_URL", + std::env::var("LM_STUDIO_BASE_URL").ok(), + ), + ("config.local_ai.base_url", local_ai.base_url.clone()), + ] { + let Some(raw) = candidate else { + tracing::trace!(source, "[lm-studio] base URL candidate missing"); + continue; + }; + tracing::trace!( + source, + raw = %redact_url_for_log(&raw), + "[lm-studio] inspecting base URL candidate" + ); + if let Some(normalized) = normalize_lm_studio_base_url(&raw) { + tracing::debug!( + source, + base_url = %redact_url_for_log(&normalized), + "[lm-studio] selected normalized base URL" + ); + return normalized; + } + tracing::trace!(source, "[lm-studio] rejected blank base URL candidate"); + } + + tracing::debug!( + base_url = %DEFAULT_LM_STUDIO_BASE_URL, + "[lm-studio] using default base URL" + ); + DEFAULT_LM_STUDIO_BASE_URL.to_string() +} + +pub(crate) fn normalize_lm_studio_base_url(raw: &str) -> Option { + let trimmed = raw.trim().trim_end_matches('/'); + tracing::trace!( + raw = %redact_url_for_log(raw), + trimmed = %redact_url_for_log(trimmed), + "[lm-studio] normalizing base URL" + ); + if trimmed.is_empty() { + tracing::trace!("[lm-studio] base URL normalization rejected blank input"); + return None; + } + + let with_scheme = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + tracing::trace!( + with_scheme = %redact_url_for_log(&with_scheme), + "[lm-studio] base URL scheme normalized" + ); + + let without_known_endpoint = with_scheme + .trim_end_matches("/chat/completions") + .trim_end_matches("/models") + .trim_end_matches('/') + .to_string(); + tracing::trace!( + without_known_endpoint = %redact_url_for_log(&without_known_endpoint), + "[lm-studio] base URL endpoint suffix normalized" + ); + + if without_known_endpoint.ends_with("/v1") { + tracing::trace!( + appended_v1 = false, + base_url = %redact_url_for_log(&without_known_endpoint), + "[lm-studio] base URL normalization complete" + ); + Some(without_known_endpoint) + } else { + let normalized = format!("{without_known_endpoint}/v1"); + tracing::trace!( + appended_v1 = true, + base_url = %redact_url_for_log(&normalized), + "[lm-studio] base URL normalization complete" + ); + Some(normalized) + } +} + +pub(crate) fn apply_lm_studio_auth( + request: reqwest::RequestBuilder, + config: &Config, +) -> reqwest::RequestBuilder { + match config.local_ai.api_key.as_deref().map(str::trim) { + Some(key) if !key.is_empty() => { + tracing::trace!( + api_key_present = true, + api_key_len = key.len(), + "[lm-studio] auth applied" + ); + request.bearer_auth(key) + } + _ => { + tracing::trace!(api_key_present = false, "[lm-studio] auth skipped"); + request + } + } +} + +fn redact_url_for_log(raw: &str) -> String { + let trimmed = raw.trim(); + let parsed = + url::Url::parse(trimmed).or_else(|_| url::Url::parse(&format!("http://{trimmed}"))); + let Ok(mut parsed) = parsed else { + return trimmed.to_string(); + }; + if !parsed.username().is_empty() { + let _ = parsed.set_username("redacted"); + } + if parsed.password().is_some() { + let _ = parsed.set_password(Some("redacted")); + } + parsed.to_string().trim_end_matches('/').to_string() +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LmStudioModelsResponse { + #[serde(default)] + pub data: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(crate) struct LmStudioModel { + pub id: String, + #[serde(default)] + pub object: Option, + #[serde(default)] + pub owned_by: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct LmStudioChatCompletionRequest { + pub model: String, + pub messages: Vec, + pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LmStudioChatMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LmStudioChatCompletionResponse { + #[serde(default)] + pub choices: Vec, + #[serde(default)] + pub usage: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LmStudioChatChoice { + pub message: LmStudioChatResponseMessage, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LmStudioChatResponseMessage { + #[serde(default)] + pub content: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LmStudioUsage { + #[serde(default)] + pub prompt_tokens: Option, + #[serde(default)] + pub completion_tokens: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_lm_studio_base_url_defaults_scheme_and_v1() { + assert_eq!( + normalize_lm_studio_base_url("localhost:1234").as_deref(), + Some("http://localhost:1234/v1") + ); + } + + #[test] + fn normalize_lm_studio_base_url_preserves_existing_v1() { + assert_eq!( + normalize_lm_studio_base_url("http://127.0.0.1:1234/v1/").as_deref(), + Some("http://127.0.0.1:1234/v1") + ); + } + + #[test] + fn normalize_lm_studio_base_url_strips_known_endpoint_suffix() { + assert_eq!( + normalize_lm_studio_base_url("http://127.0.0.1:1234/v1/chat/completions").as_deref(), + Some("http://127.0.0.1:1234/v1") + ); + assert_eq!( + normalize_lm_studio_base_url("http://127.0.0.1:1234/v1/models").as_deref(), + Some("http://127.0.0.1:1234/v1") + ); + } +} diff --git a/src/openhuman/local_ai/mod.rs b/src/openhuman/local_ai/mod.rs index 629aeaff5..596e98397 100644 --- a/src/openhuman/local_ai/mod.rs +++ b/src/openhuman/local_ai/mod.rs @@ -1,4 +1,4 @@ -//! Bundled local AI stack (Ollama, whisper.cpp, Piper). +//! Bundled local AI stack (Ollama / LM Studio, whisper.cpp, Piper). #[cfg(test)] pub(crate) static LOCAL_AI_TEST_MUTEX: once_cell::sync::Lazy> = @@ -22,12 +22,14 @@ pub mod sentiment; mod install; pub(crate) mod install_piper; pub(crate) mod install_whisper; +pub(crate) mod lm_studio_api; pub(crate) mod model_ids; mod ollama_api; mod process_util; pub(crate) use ollama_api::{ollama_base_url, OLLAMA_BASE_URL}; mod parse; pub(crate) mod paths; +pub(crate) mod provider; mod service; mod types; pub(crate) mod voice_install_common; diff --git a/src/openhuman/local_ai/model_ids.rs b/src/openhuman/local_ai/model_ids.rs index 99b1f858c..754a835ae 100644 --- a/src/openhuman/local_ai/model_ids.rs +++ b/src/openhuman/local_ai/model_ids.rs @@ -1,11 +1,14 @@ //! Resolved model / voice IDs from [`crate::openhuman::config::Config`]. //! -//! All `effective_*` functions enforce the MVP model allowlist: if a resolved +//! Most `effective_*` functions enforce the MVP model allowlist: if a resolved //! model ID is not in the allowlist the function silently falls back to the -//! default MVP model and logs a warning. This prevents config-file edits from -//! bypassing the MVP tier restriction. +//! default MVP model and logs a warning. `effective_chat_model_id` intentionally +//! bypasses that allowlist for LM Studio so user-managed model IDs are passed +//! through unchanged; the generic `effective_*` helpers still enforce the MVP +//! tier restriction for OpenHuman-managed Ollama assets. use crate::openhuman::config::Config; +use crate::openhuman::local_ai::provider::{provider_from_config, LocalAiProvider}; pub(crate) const DEFAULT_OLLAMA_MODEL: &str = "gemma3:1b-it-qat"; pub(crate) const DEFAULT_OLLAMA_VISION_MODEL: &str = ""; @@ -73,6 +76,17 @@ fn enforce_mvp_embedding_allowlist(resolved: &str) -> String { } pub(crate) fn effective_chat_model_id(config: &Config) -> String { + let provider = provider_from_config(config); + if provider == LocalAiProvider::LmStudio { + let model_id = raw_chat_model_id(config); + tracing::debug!( + provider = provider.as_str(), + has_model = !model_id.is_empty(), + "[local_ai] effective_chat_model_id: using provider-managed model id" + ); + return model_id; + } + let raw = if !config.local_ai.chat_model_id.trim().is_empty() { config.local_ai.chat_model_id.trim() } else { @@ -92,6 +106,25 @@ pub(crate) fn effective_chat_model_id(config: &Config) -> String { enforce_mvp_chat_allowlist(raw) } +fn raw_chat_model_id(config: &Config) -> String { + // For LM Studio the user must set `local_ai.chat_model_id` explicitly — + // there is no sensible Ollama-branded default to fall back to. Return an + // empty string so callers (diagnostics, status) surface the missing-model + // warning rather than silently requesting "gemma3:1b-it-qat" from LM Studio. + let raw = if !config.local_ai.chat_model_id.trim().is_empty() { + config.local_ai.chat_model_id.trim() + } else { + config.local_ai.model_id.trim() + }; + if raw.is_empty() { + tracing::debug!( + provider = "lm_studio", + "[local_ai] raw_chat_model_id: no LM Studio chat model configured" + ); + } + raw.to_string() +} + pub(crate) fn effective_vision_model_id(config: &Config) -> String { let raw = config.local_ai.vision_model_id.trim(); if raw.is_empty() { @@ -171,6 +204,29 @@ mod tests { assert_eq!(effective_chat_model_id(&config), "gemma3:1b-it-qat"); } + #[test] + fn chat_model_allows_custom_ids_for_lm_studio() { + let mut config = test_config(); + config.local_ai.provider = "lm_studio".to_string(); + config.local_ai.chat_model_id = "publisher/custom-model-7b".to_string(); + assert_eq!( + effective_chat_model_id(&config), + "publisher/custom-model-7b" + ); + } + + #[test] + fn lm_studio_chat_model_returns_empty_when_no_model_configured() { + // LM Studio has no sensible Ollama-branded default — an empty model ID + // surfaces the missing-model warning in diagnostics / status rather than + // silently sending "gemma3:1b-it-qat" to an LM Studio server. + let mut config = test_config(); + config.local_ai.provider = "lm_studio".to_string(); + config.local_ai.chat_model_id = String::new(); + config.local_ai.model_id = String::new(); + assert_eq!(effective_chat_model_id(&config), ""); + } + #[test] fn chat_model_rejects_non_mvp_models() { let mut config = test_config(); diff --git a/src/openhuman/local_ai/provider.rs b/src/openhuman/local_ai/provider.rs new file mode 100644 index 000000000..772651dcc --- /dev/null +++ b/src/openhuman/local_ai/provider.rs @@ -0,0 +1,57 @@ +//! Local AI provider selection helpers. + +use crate::openhuman::config::Config; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocalAiProvider { + Ollama, + LmStudio, +} + +impl LocalAiProvider { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Ollama => "ollama", + Self::LmStudio => "lm_studio", + } + } + + pub(crate) fn display_name(self) -> &'static str { + match self { + Self::Ollama => "Ollama", + Self::LmStudio => "LM Studio", + } + } +} + +pub(crate) fn normalize_provider(value: &str) -> String { + match value.trim().to_ascii_lowercase().as_str() { + "lmstudio" | "lm-studio" | "lm_studio" => LocalAiProvider::LmStudio.as_str().to_string(), + _ => LocalAiProvider::Ollama.as_str().to_string(), + } +} + +pub(crate) fn provider_from_config(config: &Config) -> LocalAiProvider { + match normalize_provider(&config.local_ai.provider).as_str() { + "lm_studio" => LocalAiProvider::LmStudio, + _ => LocalAiProvider::Ollama, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_provider_accepts_lm_studio_aliases() { + assert_eq!(normalize_provider("lmstudio"), "lm_studio"); + assert_eq!(normalize_provider("lm-studio"), "lm_studio"); + assert_eq!(normalize_provider("LM_Studio"), "lm_studio"); + } + + #[test] + fn normalize_provider_falls_back_to_ollama() { + assert_eq!(normalize_provider(""), "ollama"); + assert_eq!(normalize_provider("unknown"), "ollama"); + } +} diff --git a/src/openhuman/local_ai/service/assets.rs b/src/openhuman/local_ai/service/assets.rs index 6dfcbf6af..8b281cbc7 100644 --- a/src/openhuman/local_ai/service/assets.rs +++ b/src/openhuman/local_ai/service/assets.rs @@ -4,12 +4,13 @@ use futures_util::TryStreamExt; use crate::openhuman::config::Config; use crate::openhuman::local_ai::model_ids; -use log::debug; +use tracing::{debug, trace}; use crate::openhuman::local_ai::paths::{ resolve_stt_model_path, resolve_tts_voice_path, stt_model_target_path, tts_model_target_path, }; use crate::openhuman::local_ai::presets::{self, VisionMode}; +use crate::openhuman::local_ai::provider::{provider_from_config, LocalAiProvider}; use crate::openhuman::local_ai::types::{ LocalAiAssetStatus, LocalAiAssetsStatus, LocalAiDownloadProgressItem, LocalAiDownloadsProgress, }; @@ -24,21 +25,211 @@ impl LocalAiService { let stt_model = model_ids::effective_stt_model_id(config); let tts_voice = model_ids::effective_tts_voice_id(config); + let provider = provider_from_config(config); + let correlation_id = uuid::Uuid::new_v4().to_string(); + trace!( + target: "local_ai::assets", + %correlation_id, + provider = %provider.as_str(), + chat_model = %chat_model, + vision_model = %vision_model, + embedding_model = %embedding_model, + "[local_ai:assets:provider_routing] entry" + ); + // Pre-flight precondition: if no Ollama binary exists anywhere - // discoverable, every `has_model` call will fail (or time out). Skip - // the HTTP probes entirely and report a clean "missing" state with - // `ollama_available: false` so the UI can render an "Install Ollama" - // CTA instead of perpetually-empty model state. - let ollama_available = self.ollama_binary_present(config); - let (chat_ready, vision_ready, embedding_ready) = if ollama_available { - ( - self.has_model(&chat_model).await.unwrap_or(false), - self.has_model(&vision_model).await.unwrap_or(false), - self.has_model(&embedding_model).await.unwrap_or(false), - ) + // discoverable, every Ollama-backed `has_model` call will fail (or + // time out). LM Studio still delegates embeddings to Ollama in this + // first provider slice, so it needs the same pre-flight for the + // embedding branch. + let uses_ollama_assets = matches!( + provider, + LocalAiProvider::Ollama | LocalAiProvider::LmStudio + ); + let ollama_available = if uses_ollama_assets { + let present = self.ollama_binary_present(config); + debug!( + target: "local_ai::assets", + %correlation_id, + provider = %provider.as_str(), + ollama_available = present, + "[local_ai:assets:provider_routing] ollama binary check" + ); + present } else { + true + }; + let (chat_ready, vision_ready, embedding_ready) = if provider == LocalAiProvider::LmStudio { + trace!( + target: "local_ai::assets", + %correlation_id, + branch = "lm_studio", + "[local_ai:assets:provider_routing] selected provider branch" + ); + let chat_ready = match self.has_lm_studio_model(config, &chat_model).await { + Ok(ready) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "lm_studio", + model = %chat_model, + ready, + "[local_ai:assets:provider_routing] lm studio chat model check" + ); + ready + } + Err(err) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "lm_studio", + model = %chat_model, + error = %err, + "[local_ai:assets:provider_routing] lm studio chat model check failed" + ); + false + } + }; + let embedding_ready = if ollama_available { + match self.has_model(&embedding_model).await { + Ok(ready) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "ollama", + model = %embedding_model, + ready, + "[local_ai:assets:provider_routing] lm studio embedding ollama model check" + ); + ready + } + Err(err) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "ollama", + model = %embedding_model, + error = %err, + "[local_ai:assets:provider_routing] lm studio embedding ollama model check failed" + ); + false + } + } + } else { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "ollama", + model = %embedding_model, + "[local_ai:assets:provider_routing] lm studio embedding check skipped; ollama binary missing" + ); + false + }; + (chat_ready, false, embedding_ready) + } else if ollama_available { + trace!( + target: "local_ai::assets", + %correlation_id, + branch = "ollama", + "[local_ai:assets:provider_routing] selected provider branch" + ); + let chat_ready = match self.has_model(&chat_model).await { + Ok(ready) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "ollama", + capability = "chat", + model = %chat_model, + ready, + "[local_ai:assets:provider_routing] ollama model check" + ); + ready + } + Err(err) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "ollama", + capability = "chat", + model = %chat_model, + error = %err, + "[local_ai:assets:provider_routing] ollama model check failed" + ); + false + } + }; + let vision_ready = match self.has_model(&vision_model).await { + Ok(ready) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "ollama", + capability = "vision", + model = %vision_model, + ready, + "[local_ai:assets:provider_routing] ollama model check" + ); + ready + } + Err(err) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "ollama", + capability = "vision", + model = %vision_model, + error = %err, + "[local_ai:assets:provider_routing] ollama model check failed" + ); + false + } + }; + let embedding_ready = match self.has_model(&embedding_model).await { + Ok(ready) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "ollama", + capability = "embedding", + model = %embedding_model, + ready, + "[local_ai:assets:provider_routing] ollama model check" + ); + ready + } + Err(err) => { + debug!( + target: "local_ai::assets", + %correlation_id, + provider = "ollama", + capability = "embedding", + model = %embedding_model, + error = %err, + "[local_ai:assets:provider_routing] ollama model check failed" + ); + false + } + }; + (chat_ready, vision_ready, embedding_ready) + } else { + trace!( + target: "local_ai::assets", + %correlation_id, + branch = "ollama_missing_binary", + "[local_ai:assets:provider_routing] selected provider branch" + ); (false, false, false) }; + trace!( + target: "local_ai::assets", + %correlation_id, + chat_ready, + vision_ready, + embedding_ready, + ollama_available, + "[local_ai:assets:provider_routing] exit" + ); let stt_resolve = resolve_stt_model_path(config); let tts_resolve = resolve_tts_voice_path(config); @@ -94,42 +285,60 @@ impl LocalAiService { }; let vision_mode = presets::vision_mode_for_config(&config.local_ai); + let embedding_path = Some(format!("ollama://{embedding_model}")); Ok(LocalAiAssetsStatus { chat: LocalAiAssetStatus { state: if chat_ready { "ready" } else { "missing" }.to_string(), id: chat_model, - provider: "ollama".to_string(), + provider: provider.as_str().to_string(), path: None, - warning: None, + warning: (provider == LocalAiProvider::LmStudio && !chat_ready).then(|| { + "Load this model in LM Studio or update local_ai.chat_model_id.".to_string() + }), }, vision: LocalAiAssetStatus { - state: match vision_mode { - VisionMode::Disabled => "disabled", - VisionMode::Ondemand if vision_ready => "ready", - VisionMode::Ondemand => "ondemand", - VisionMode::Bundled if vision_ready => "ready", - VisionMode::Bundled => "missing", - } - .to_string(), + state: if provider == LocalAiProvider::LmStudio { + "disabled".to_string() + } else { + match vision_mode { + VisionMode::Disabled => "disabled", + VisionMode::Ondemand if vision_ready => "ready", + VisionMode::Ondemand => "ondemand", + VisionMode::Bundled if vision_ready => "ready", + VisionMode::Bundled => "missing", + } + .to_string() + }, id: vision_model, - provider: "ollama".to_string(), + provider: provider.as_str().to_string(), path: None, - warning: match vision_mode { - VisionMode::Disabled => { - Some("Vision is disabled for this RAM tier.".to_string()) + warning: if provider == LocalAiProvider::LmStudio { + Some("Vision is not part of the first LM Studio provider slice.".to_string()) + } else { + match vision_mode { + VisionMode::Disabled => { + Some("Vision is disabled for this RAM tier.".to_string()) + } + VisionMode::Ondemand if !vision_ready => { + Some("Vision model will download on first vision request.".to_string()) + } + _ => None, } - VisionMode::Ondemand if !vision_ready => { - Some("Vision model will download on first vision request.".to_string()) - } - _ => None, }, }, embedding: LocalAiAssetStatus { state: if embedding_ready { "ready" } else { "missing" }.to_string(), id: embedding_model, - provider: "ollama".to_string(), - path: None, - warning: None, + provider: if provider == LocalAiProvider::LmStudio { + "ollama".to_string() + } else { + provider.as_str().to_string() + }, + path: embedding_path, + warning: (provider == LocalAiProvider::LmStudio).then(|| { + "Embeddings still use the existing Ollama path in this first LM Studio slice." + .to_string() + }), }, stt: LocalAiAssetStatus { state: stt_state.to_string(), @@ -267,12 +476,126 @@ impl LocalAiService { }) } + fn finalize_lm_studio_download_status( + &self, + config: &Config, + embedding_state: Option<&'static str>, + stt_state: Option<&'static str>, + tts_state: Option<&'static str>, + warning: Option, + ) { + let mut status = self.status.lock(); + status.state = "ready".to_string(); + status.vision_state = "disabled".to_string(); + if let Some(state) = embedding_state { + status.embedding_state = state.to_string(); + } else if !config.local_ai.preload_embedding_model { + status.embedding_state = "idle".to_string(); + } else if status.embedding_state != "ready" { + status.embedding_state = "missing".to_string(); + } + if let Some(state) = stt_state { + status.stt_state = state.to_string(); + } else if !config.local_ai.preload_stt_model { + status.stt_state = "idle".to_string(); + } + if let Some(state) = tts_state { + status.tts_state = state.to_string(); + } else if !config.local_ai.preload_tts_voice { + status.tts_state = "idle".to_string(); + } + status.warning = warning; + status.error_detail = None; + status.error_category = None; + status.download_progress = None; + status.downloaded_bytes = None; + status.total_bytes = None; + status.download_speed_bps = None; + status.eta_seconds = None; + } + pub async fn download_all_models(&self, config: &Config) -> Result<(), String> { if !config.local_ai.runtime_enabled { return Err("local ai is disabled".to_string()); } let _guard = self.bootstrap_lock.lock().await; + if provider_from_config(config) == LocalAiProvider::LmStudio { + self.ensure_lm_studio_available(config).await?; + let mut embedding_state = None; + if config.local_ai.preload_embedding_model { + let model_id = model_ids::effective_embedding_model_id(config); + { + let mut status = self.status.lock(); + status.state = "downloading".to_string(); + status.embedding_state = "downloading".to_string(); + status.warning = Some(format!( + "Downloading embedding model via Ollama: `{model_id}`" + )); + } + if let Err(err) = async { + self.ensure_ollama_server(config).await?; + self.ensure_ollama_model_available(&model_id, "embedding") + .await + } + .await + { + log::warn!( + "[local_ai] LM Studio download_all_models embedding preload failed: {err}" + ); + self.finalize_lm_studio_download_status( + config, + Some("missing"), + None, + None, + None, + ); + return Err(err); + } + embedding_state = Some("ready"); + } + let mut stt_warning = None; + let mut stt_state = None; + if config.local_ai.preload_stt_model { + if let Err(err) = self.ensure_stt_asset_available(config).await { + log::warn!( + "[local_ai] LM Studio download_all_models STT preload failed: {err}" + ); + stt_state = Some("missing"); + stt_warning = Some(err); + } else { + stt_state = Some("ready"); + } + } + let mut tts_warning = None; + let mut tts_state = None; + if config.local_ai.preload_tts_voice { + if let Err(err) = self.ensure_tts_asset_available(config).await { + log::warn!( + "[local_ai] LM Studio download_all_models TTS preload failed: {err}" + ); + tts_state = Some("missing"); + tts_warning = Some(err); + } else { + tts_state = Some("ready"); + } + } + let warning = match (stt_warning, tts_warning) { + (Some(a), Some(b)) => Some(format!("{a}; {b}")), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }; + self.finalize_lm_studio_download_status( + config, + embedding_state, + stt_state, + tts_state, + warning, + ); + return Ok(()); + } + self.ensure_ollama_server(config).await?; let mut steps = vec![ @@ -354,6 +677,14 @@ impl LocalAiService { let _guard = self.bootstrap_lock.lock().await; let capability = capability.trim().to_ascii_lowercase(); + if provider_from_config(config) == LocalAiProvider::LmStudio + && matches!(capability.as_str(), "chat" | "vision") + { + return Err( + "LM Studio manages chat and vision model downloads. Load the model in LM Studio, then retry." + .to_string(), + ); + } match capability.as_str() { "chat" => { self.ensure_ollama_server(config).await?; diff --git a/src/openhuman/local_ai/service/bootstrap.rs b/src/openhuman/local_ai/service/bootstrap.rs index 90441ca90..5e21d7bf5 100644 --- a/src/openhuman/local_ai/service/bootstrap.rs +++ b/src/openhuman/local_ai/service/bootstrap.rs @@ -2,6 +2,7 @@ use crate::openhuman::config::Config; use crate::openhuman::local_ai::device::DeviceProfile; use crate::openhuman::local_ai::model_ids; use crate::openhuman::local_ai::presets::{self, VisionMode}; +use crate::openhuman::local_ai::provider::{provider_from_config, LocalAiProvider}; use crate::openhuman::local_ai::types::LocalAiStatus; use super::LocalAiService; @@ -12,6 +13,7 @@ impl LocalAiService { let vision_model_id = model_ids::effective_vision_model_id(config); let embedding_model_id = model_ids::effective_embedding_model_id(config); let vision_mode = vision_mode_str(config); + let provider = provider_from_config(config); Self { whisper: super::whisper_engine::new_handle(), status: parking_lot::Mutex::new(LocalAiStatus { @@ -28,7 +30,7 @@ impl LocalAiService { embedding_state: "idle".to_string(), stt_state: "idle".to_string(), tts_state: "idle".to_string(), - provider: "ollama".to_string(), + provider: provider.as_str().to_string(), download_progress: None, downloaded_bytes: None, total_bytes: None, @@ -37,8 +39,8 @@ impl LocalAiService { warning: None, error_detail: None, error_category: None, - model_path: Some(format!("ollama://{}", model_id)), - active_backend: "ollama".to_string(), + model_path: Some(model_path_for_config(config)), + active_backend: provider.as_str().to_string(), backend_reason: None, last_latency_ms: None, prompt_toks_per_sec: None, @@ -76,6 +78,7 @@ impl LocalAiService { pub fn reset_to_idle(&self, config: &Config) { let model_id = model_ids::effective_chat_model_id(config); let vision_mode = vision_mode_str(config); + let provider = provider_from_config(config); let mut status = self.status.lock(); status.state = "idle".to_string(); status.model_id = model_id.clone(); @@ -90,7 +93,7 @@ impl LocalAiService { status.embedding_state = "idle".to_string(); status.stt_state = "idle".to_string(); status.tts_state = "idle".to_string(); - status.provider = "ollama".to_string(); + status.provider = provider.as_str().to_string(); status.download_progress = None; status.downloaded_bytes = None; status.total_bytes = None; @@ -99,8 +102,8 @@ impl LocalAiService { status.warning = None; status.error_detail = None; status.error_category = None; - status.model_path = Some(format!("ollama://{}", model_id)); - status.active_backend = "ollama".to_string(); + status.model_path = Some(model_path_for_config(config)); + status.active_backend = provider.as_str().to_string(); status.backend_reason = None; status.last_latency_ms = None; status.prompt_toks_per_sec = None; @@ -133,6 +136,7 @@ impl LocalAiService { } { + let provider = provider_from_config(&effective_config); let mut status = self.status.lock(); status.model_id = model_ids::effective_chat_model_id(&effective_config); status.chat_model_id = model_ids::effective_chat_model_id(&effective_config); @@ -142,8 +146,12 @@ impl LocalAiService { status.tts_voice_id = model_ids::effective_tts_voice_id(&effective_config); status.quantization = model_ids::effective_quantization(&effective_config); status.state = "loading".to_string(); + status.provider = provider.as_str().to_string(); status.vision_mode = vision_mode_str(&effective_config); - status.warning = Some("Connecting to local Ollama runtime".to_string()); + status.warning = Some(format!( + "Connecting to local {} runtime", + provider.display_name() + )); status.download_progress = None; status.downloaded_bytes = None; status.total_bytes = None; @@ -151,12 +159,136 @@ impl LocalAiService { status.eta_seconds = None; status.error_detail = None; status.error_category = None; - status.active_backend = "ollama".to_string(); - status.backend_reason = Some("Inference delegated to Ollama runtime".to_string()); - status.model_path = Some(format!( - "ollama://{}", - model_ids::effective_chat_model_id(&effective_config) + status.active_backend = provider.as_str().to_string(); + status.backend_reason = Some(format!( + "Inference delegated to {} runtime", + provider.display_name() )); + status.model_path = Some(model_path_for_config(&effective_config)); + } + + if provider_from_config(&effective_config) == LocalAiProvider::LmStudio { + log::debug!( + "[local_ai] LM Studio bootstrap branch entry preload_embedding={} preload_stt={} preload_tts={}", + effective_config.local_ai.preload_embedding_model, + effective_config.local_ai.preload_stt_model, + effective_config.local_ai.preload_tts_voice + ); + log::trace!("[local_ai] LM Studio bootstrap availability check start"); + if let Err(err) = self.ensure_lm_studio_available(&effective_config).await { + log::debug!("[local_ai] LM Studio bootstrap degraded: {err}"); + let mut status = self.status.lock(); + status.state = "degraded".to_string(); + status.error_category = Some("server".to_string()); + status.warning = Some(err); + return; + } + log::debug!("[local_ai] LM Studio bootstrap availability check succeeded"); + + log::trace!( + "[local_ai] LM Studio bootstrap embedding preload decision: {}", + effective_config.local_ai.preload_embedding_model + ); + if effective_config.local_ai.preload_embedding_model { + let embedding_model = model_ids::effective_embedding_model_id(&effective_config); + log::debug!( + "[local_ai] LM Studio bootstrap embedding preload start model={embedding_model}" + ); + { + let mut status = self.status.lock(); + status.state = "downloading".to_string(); + status.embedding_state = "downloading".to_string(); + status.warning = Some(format!( + "Downloading embedding model via Ollama: `{embedding_model}`" + )); + } + if let Err(err) = async { + log::trace!( + "[local_ai] LM Studio bootstrap embedding ensure_ollama_server start" + ); + self.ensure_ollama_server(&effective_config).await?; + log::trace!( + "[local_ai] LM Studio bootstrap embedding ensure_ollama_server succeeded" + ); + log::trace!( + "[local_ai] LM Studio bootstrap embedding ensure_ollama_model_available start model={embedding_model}" + ); + self.ensure_ollama_model_available(&embedding_model, "embedding") + .await?; + log::trace!( + "[local_ai] LM Studio bootstrap embedding ensure_ollama_model_available succeeded model={embedding_model}" + ); + Ok::<(), String>(()) + } + .await + { + log::warn!("[local_ai] LM Studio bootstrap embedding preload failed: {err}"); + self.status.lock().embedding_state = "missing".to_string(); + } else { + log::debug!( + "[local_ai] LM Studio bootstrap embedding preload succeeded model={embedding_model}" + ); + self.status.lock().embedding_state = "ready".to_string(); + } + } + + log::trace!( + "[local_ai] LM Studio bootstrap STT preload decision: {}", + effective_config.local_ai.preload_stt_model + ); + if effective_config.local_ai.preload_stt_model { + log::debug!("[local_ai] LM Studio bootstrap STT preload start"); + if let Err(err) = self.ensure_stt_asset_available(&effective_config).await { + log::warn!("[local_ai] LM Studio bootstrap STT preload failed: {err}"); + self.status.lock().stt_state = "missing".to_string(); + } else { + log::debug!("[local_ai] LM Studio bootstrap STT preload succeeded"); + } + } + log::trace!( + "[local_ai] LM Studio bootstrap TTS preload decision: {}", + effective_config.local_ai.preload_tts_voice + ); + if effective_config.local_ai.preload_tts_voice { + log::debug!("[local_ai] LM Studio bootstrap TTS preload start"); + if let Err(err) = self.ensure_tts_asset_available(&effective_config).await { + log::warn!("[local_ai] LM Studio bootstrap TTS preload failed: {err}"); + self.status.lock().tts_state = "missing".to_string(); + } else { + log::debug!("[local_ai] LM Studio bootstrap TTS preload succeeded"); + } + } + + let mut status = self.status.lock(); + status.state = "ready".to_string(); + status.vision_state = "disabled".to_string(); + if !effective_config.local_ai.preload_embedding_model { + status.embedding_state = "idle".to_string(); + } else if status.embedding_state != "ready" { + status.embedding_state = "missing".to_string(); + } + if !effective_config.local_ai.preload_stt_model { + status.stt_state = "idle".to_string(); + } + if !effective_config.local_ai.preload_tts_voice { + status.tts_state = "idle".to_string(); + } + status.warning = None; + status.error_detail = None; + status.error_category = None; + status.download_progress = None; + status.downloaded_bytes = None; + status.total_bytes = None; + status.download_speed_bps = None; + status.eta_seconds = None; + status.model_path = Some(model_path_for_config(&effective_config)); + log::debug!( + "[local_ai] LM Studio bootstrap ready embedding_state={} stt_state={} tts_state={}", + status.embedding_state, + status.stt_state, + status.tts_state + ); + return; } if let Err(first_err) = self.ensure_ollama_server(&effective_config).await { @@ -251,10 +383,7 @@ impl LocalAiService { status.total_bytes = None; status.download_speed_bps = None; status.eta_seconds = None; - status.model_path = Some(format!( - "ollama://{}", - model_ids::effective_chat_model_id(&effective_config) - )); + status.model_path = Some(model_path_for_config(&effective_config)); } pub fn should_run_memory_autosummary(&self, config: &Config) -> bool { @@ -340,6 +469,14 @@ fn vision_mode_str(config: &Config) -> String { format!("{:?}", presets::vision_mode_for_config(&config.local_ai)).to_ascii_lowercase() } +fn model_path_for_config(config: &Config) -> String { + let model_id = model_ids::effective_chat_model_id(config); + match provider_from_config(config) { + LocalAiProvider::Ollama => format!("ollama://{model_id}"), + LocalAiProvider::LmStudio => format!("lmstudio://{model_id}"), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/local_ai/service/lm_studio.rs b/src/openhuman/local_ai/service/lm_studio.rs new file mode 100644 index 000000000..218d1b5d5 --- /dev/null +++ b/src/openhuman/local_ai/service/lm_studio.rs @@ -0,0 +1,246 @@ +use crate::openhuman::config::Config; +use crate::openhuman::local_ai::lm_studio_api::{ + apply_lm_studio_auth, lm_studio_base_url, LmStudioChatCompletionRequest, + LmStudioChatCompletionResponse, LmStudioChatMessage, LmStudioModelsResponse, +}; +use crate::openhuman::local_ai::model_ids; +use crate::openhuman::local_ai::ollama_api::OllamaModelTag; + +use super::LocalAiService; + +fn diagnostic_body_snippet(body: &str) -> String { + const MAX_CHARS: usize = 512; + let mut snippet: String = body.chars().take(MAX_CHARS).collect(); + if body.chars().count() > MAX_CHARS { + snippet.push_str("..."); + } + snippet +} + +pub(in crate::openhuman::local_ai::service) struct LmStudioCompletionOutcome { + pub reply: String, + pub prompt_tokens: Option, + pub completion_tokens: Option, +} + +impl LocalAiService { + pub(in crate::openhuman::local_ai::service) async fn ensure_lm_studio_available( + &self, + config: &Config, + ) -> Result<(), String> { + // Probe connectivity only — the server must be reachable. Whether any + // models are loaded is a separate concern surfaced via diagnostics and + // the asset-status warning, so bootstrap can succeed and the UI can + // show an actionable "load a model in LM Studio" CTA instead of a + // hard error. + self.list_lm_studio_models(config).await?; + Ok(()) + } + + pub(in crate::openhuman::local_ai::service) async fn list_lm_studio_models( + &self, + config: &Config, + ) -> Result, String> { + let base = lm_studio_base_url(config); + let url = format!("{base}/models"); + tracing::debug!( + target: "local_ai::lm_studio", + %base, + %url, + "[local_ai:lm_studio] list_models: sending GET" + ); + + let request = self + .http + .get(&url) + .timeout(std::time::Duration::from_secs(5)); + let response = apply_lm_studio_auth(request, config) + .send() + .await + .map_err(|e| { + tracing::debug!( + target: "local_ai::lm_studio", + %url, + error = %e, + "[local_ai:lm_studio] list_models: request failed" + ); + format!("lm studio models request failed: {e}") + })?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let detail = body.trim(); + tracing::debug!( + target: "local_ai::lm_studio", + %url, + %status, + body = %diagnostic_body_snippet(&body), + "[local_ai:lm_studio] list_models: non-success response" + ); + return Err(format!( + "lm studio models failed with status {}{}", + status, + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } + )); + } + + let body = response.text().await.map_err(|e| { + tracing::debug!( + target: "local_ai::lm_studio", + %url, + error = %e, + "[local_ai:lm_studio] list_models: body read failed" + ); + format!("lm studio models body read failed: {e}") + })?; + let payload: LmStudioModelsResponse = serde_json::from_str(&body).map_err(|e| { + tracing::debug!( + target: "local_ai::lm_studio", + %url, + error = %e, + body = %diagnostic_body_snippet(&body), + "[local_ai:lm_studio] list_models: parse failed" + ); + format!("lm studio models parse failed: {e}") + })?; + + Ok(payload + .data + .into_iter() + .map(|model| OllamaModelTag { + name: model.id, + size: None, + modified_at: None, + }) + .collect()) + } + + pub(in crate::openhuman::local_ai::service) async fn has_lm_studio_model( + &self, + config: &Config, + model: &str, + ) -> Result { + let target = model.trim().to_ascii_lowercase(); + Ok(self + .list_lm_studio_models(config) + .await? + .into_iter() + .any(|m| m.name.to_ascii_lowercase() == target)) + } + + pub(in crate::openhuman::local_ai::service) async fn lm_studio_chat_completion( + &self, + config: &Config, + messages: Vec, + max_tokens: Option, + temperature: f32, + allow_empty: bool, + ) -> Result { + let base = lm_studio_base_url(config); + let url = format!("{base}/chat/completions"); + let model = model_ids::effective_chat_model_id(config); + + tracing::debug!( + target: "local_ai::lm_studio", + %url, + %model, + message_count = messages.len(), + max_tokens = ?max_tokens, + "[local_ai:lm_studio] chat completion: sending POST" + ); + + let body = LmStudioChatCompletionRequest { + model, + messages, + stream: false, + temperature: Some(temperature), + max_tokens, + }; + + let request = self + .http + .post(&url) + .timeout(std::time::Duration::from_secs(120)) + .json(&body); + let response = apply_lm_studio_auth(request, config) + .send() + .await + .map_err(|e| { + tracing::debug!( + target: "local_ai::lm_studio", + %url, + error = %e, + "[local_ai:lm_studio] chat completion: request failed" + ); + format!("lm studio chat request failed: {e}") + })?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let detail = body.trim(); + tracing::debug!( + target: "local_ai::lm_studio", + %url, + %status, + body = %diagnostic_body_snippet(&body), + "[local_ai:lm_studio] chat completion: non-success response" + ); + return Err(format!( + "lm studio chat failed with status {}{}", + status, + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } + )); + } + + let body = response.text().await.map_err(|e| { + tracing::debug!( + target: "local_ai::lm_studio", + %url, + error = %e, + "[local_ai:lm_studio] chat completion: body read failed" + ); + format!("lm studio chat response body read failed: {e}") + })?; + let payload: LmStudioChatCompletionResponse = serde_json::from_str(&body).map_err(|e| { + tracing::debug!( + target: "local_ai::lm_studio", + %url, + error = %e, + body = %diagnostic_body_snippet(&body), + "[local_ai:lm_studio] chat completion: parse failed" + ); + format!("lm studio chat response parse failed: {e}") + })?; + + let reply = payload + .choices + .first() + .and_then(|choice| choice.message.content.as_deref()) + .unwrap_or_default() + .trim() + .to_string(); + + if reply.is_empty() && !allow_empty { + return Err("lm studio returned empty content".to_string()); + } + + Ok(LmStudioCompletionOutcome { + reply, + prompt_tokens: payload.usage.as_ref().and_then(|usage| usage.prompt_tokens), + completion_tokens: payload + .usage + .as_ref() + .and_then(|usage| usage.completion_tokens), + }) + } +} diff --git a/src/openhuman/local_ai/service/mod.rs b/src/openhuman/local_ai/service/mod.rs index 0deff2fb4..d9f03cd3e 100644 --- a/src/openhuman/local_ai/service/mod.rs +++ b/src/openhuman/local_ai/service/mod.rs @@ -2,6 +2,7 @@ mod assets; mod bootstrap; +mod lm_studio; mod ollama_admin; mod public_infer; pub(crate) mod spawn_marker; diff --git a/src/openhuman/local_ai/service/ollama_admin.rs b/src/openhuman/local_ai/service/ollama_admin.rs index de7fe03d4..7479ceb1f 100644 --- a/src/openhuman/local_ai/service/ollama_admin.rs +++ b/src/openhuman/local_ai/service/ollama_admin.rs @@ -4,6 +4,7 @@ use futures_util::StreamExt; use crate::openhuman::config::Config; use crate::openhuman::local_ai::install::{find_system_ollama_binary, run_ollama_install_script}; +use crate::openhuman::local_ai::lm_studio_api::lm_studio_base_url; use crate::openhuman::local_ai::model_ids; use crate::openhuman::local_ai::ollama_api::{ ollama_base_url, OllamaModelTag, OllamaPullEvent, OllamaPullProgress, OllamaPullRequest, @@ -12,10 +13,15 @@ use crate::openhuman::local_ai::ollama_api::{ use crate::openhuman::local_ai::paths::{find_workspace_ollama_binary, workspace_ollama_binary}; use crate::openhuman::local_ai::presets::{self, VisionMode}; use crate::openhuman::local_ai::process_util::apply_no_window; +use crate::openhuman::local_ai::provider::{provider_from_config, LocalAiProvider}; use super::spawn_marker::{self, OllamaSpawnMarker}; use super::LocalAiService; +fn lm_studio_models_error_means_unreachable(error: &str) -> bool { + error.starts_with("lm studio models request failed:") +} + impl LocalAiService { pub(in crate::openhuman::local_ai::service) async fn ensure_ollama_server( &self, @@ -836,6 +842,10 @@ impl LocalAiService { /// Run full diagnostics: check Ollama server health, list installed models, /// and verify expected models are present. Returns a JSON-serializable report. pub async fn diagnostics(&self, config: &Config) -> Result { + if provider_from_config(config) == LocalAiProvider::LmStudio { + return self.lm_studio_diagnostics(config).await; + } + let base_url = ollama_base_url(); let healthy = self.ollama_healthy().await; @@ -1036,6 +1046,93 @@ impl LocalAiService { Ok(payload.models) } + async fn lm_studio_diagnostics(&self, config: &Config) -> Result { + let base_url = lm_studio_base_url(config); + let models_result = self.list_lm_studio_models(config).await; + let (models, models_error, healthy) = match models_result { + Ok(models) => (models, None, true), + Err(err) => { + let reachable = !lm_studio_models_error_means_unreachable(&err); + (vec![], Some(err), reachable) + } + }; + + let expected_chat = model_ids::effective_chat_model_id(config); + let model_names: Vec = models.iter().map(|m| m.name.to_ascii_lowercase()).collect(); + let chat_found = model_names + .iter() + .any(|name| name == &expected_chat.to_ascii_lowercase()); + + let mut issues: Vec = Vec::new(); + let mut repair_actions: Vec = Vec::new(); + + if !healthy { + let detail = models_error + .as_deref() + .map(|err| format!(": {err}")) + .unwrap_or_default(); + issues.push(format!( + "LM Studio server is not running or not reachable at {}{}", + base_url, detail + )); + repair_actions.push(serde_json::json!({ + "action": "start_lm_studio_server", + "base_url": base_url, + })); + } + if healthy && models_error.is_none() && models.is_empty() { + issues.push("LM Studio is reachable but no models are loaded".to_string()); + repair_actions.push(serde_json::json!({ + "action": "load_lm_studio_model", + })); + } else if healthy && models_error.is_none() && !chat_found { + issues.push(format!( + "Chat model `{}` is not loaded in LM Studio", + expected_chat + )); + repair_actions.push(serde_json::json!({ + "action": "load_lm_studio_model", + "model": expected_chat, + })); + } + if healthy { + if let Some(ref err) = models_error { + issues.push(format!("Failed to list LM Studio models: {err}")); + } + } + + tracing::debug!( + provider = "lm_studio", + %base_url, + healthy, + models = models.len(), + issues = issues.len(), + "[local_ai] diagnostics" + ); + + Ok(serde_json::json!({ + "provider": "lm_studio", + "lm_studio_running": healthy, + "lm_studio_base_url": base_url, + "ollama_running": false, + "ollama_base_url": serde_json::Value::Null, + "ollama_binary_path": serde_json::Value::Null, + "installed_models": models, + "vision_mode": "disabled", + "expected": { + "chat_model": expected_chat, + "chat_found": chat_found, + "embedding_model": model_ids::effective_embedding_model_id(config), + "embedding_found": false, + "vision_model": model_ids::effective_vision_model_id(config), + "vision_found": false, + }, + "issues": issues, + "repair_actions": repair_actions, + "ok": issues.is_empty(), + })) + } + fn resolve_binary_path(&self, config: &Config) -> Option { // 1. Explicit user-configured path in Settings. if let Some(ref custom) = config.local_ai.ollama_binary_path { diff --git a/src/openhuman/local_ai/service/ollama_admin_tests.rs b/src/openhuman/local_ai/service/ollama_admin_tests.rs index faa266e0b..e2a0511fa 100644 --- a/src/openhuman/local_ai/service/ollama_admin_tests.rs +++ b/src/openhuman/local_ai/service/ollama_admin_tests.rs @@ -22,6 +22,17 @@ async fn spawn_mock(app: Router) -> String { format!("http://127.0.0.1:{}", addr.port()) } +fn lm_studio_config(base: &str) -> Config { + let mut config = Config::default(); + config.local_ai.runtime_enabled = true; + config.local_ai.opt_in_confirmed = true; + config.local_ai.provider = "lm_studio".to_string(); + config.local_ai.base_url = Some(format!("{base}/v1")); + config.local_ai.model_id = "local-model".to_string(); + config.local_ai.chat_model_id = "local-model".to_string(); + config +} + #[tokio::test] async fn has_model_detects_exact_and_prefixed_tag() { let _guard = crate::openhuman::local_ai::local_ai_test_guard(); @@ -389,6 +400,166 @@ async fn list_models_errors_on_non_success() { } } +#[tokio::test] +async fn lm_studio_list_models_returns_loaded_models() { + let _guard = crate::openhuman::local_ai::local_ai_test_guard(); + + let app = Router::new().route( + "/v1/models", + get(|| async { + Json(json!({ + "object": "list", + "data": [ + { "id": "local-model", "object": "model", "owned_by": "lm-studio" }, + { "id": "second-model", "object": "model", "owned_by": "lm-studio" } + ] + })) + }), + ); + let base = spawn_mock(app).await; + let config = lm_studio_config(&base); + let service = LocalAiService::new(&config); + + let models = service + .list_lm_studio_models(&config) + .await + .expect("lm studio models"); + + assert_eq!(models.len(), 2); + assert_eq!(models[0].name, "local-model"); + assert!(service + .has_lm_studio_model(&config, "local-model") + .await + .expect("has model")); +} + +#[tokio::test] +async fn lm_studio_diagnostics_reports_loaded_chat_model() { + let _guard = crate::openhuman::local_ai::local_ai_test_guard(); + + let app = Router::new().route( + "/v1/models", + get(|| async { + Json(json!({ + "data": [ + { "id": "local-model", "object": "model", "owned_by": "lm-studio" } + ] + })) + }), + ); + let base = spawn_mock(app).await; + let config = lm_studio_config(&base); + let service = LocalAiService::new(&config); + + let diag = service.diagnostics(&config).await.expect("diagnostics"); + + assert_eq!(diag["provider"].as_str(), Some("lm_studio")); + assert_eq!(diag["lm_studio_running"], true); + assert_eq!(diag["expected"]["chat_found"], true); + assert_eq!(diag["ok"], true); +} + +#[tokio::test] +async fn lm_studio_diagnostics_flags_missing_chat_model() { + let _guard = crate::openhuman::local_ai::local_ai_test_guard(); + + let app = Router::new().route( + "/v1/models", + get(|| async { + Json(json!({ + "data": [ + { "id": "other-model", "object": "model", "owned_by": "lm-studio" } + ] + })) + }), + ); + let base = spawn_mock(app).await; + let config = lm_studio_config(&base); + let service = LocalAiService::new(&config); + + let diag = service.diagnostics(&config).await.expect("diagnostics"); + + assert_eq!(diag["provider"].as_str(), Some("lm_studio")); + assert_eq!(diag["expected"]["chat_found"], false); + assert_eq!(diag["ok"], false); + assert!(diag["issues"] + .as_array() + .unwrap() + .iter() + .any(|issue| issue.as_str().unwrap_or("").contains("local-model"))); +} + +#[tokio::test] +async fn lm_studio_diagnostics_surfaces_reachable_model_list_errors() { + let _guard = crate::openhuman::local_ai::local_ai_test_guard(); + + let app = Router::new().route("/v1/models", get(|| async { "not json" })); + let base = spawn_mock(app).await; + let config = lm_studio_config(&base); + let service = LocalAiService::new(&config); + + let diag = service.diagnostics(&config).await.expect("diagnostics"); + + assert_eq!(diag["provider"].as_str(), Some("lm_studio")); + assert_eq!(diag["lm_studio_running"], true); + assert_eq!(diag["ok"], false); + assert!(diag["issues"].as_array().unwrap().iter().any(|issue| issue + .as_str() + .unwrap_or("") + .contains("Failed to list LM Studio models"))); + assert!(!diag["repair_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action["action"].as_str() == Some("load_lm_studio_model"))); +} + +#[tokio::test] +async fn lm_studio_assets_reports_embedding_as_ollama_managed() { + let _guard = crate::openhuman::local_ai::local_ai_test_guard(); + + let app = Router::new().route( + "/v1/models", + get(|| async { + Json(json!({ + "data": [ + { "id": "local-model", "object": "model", "owned_by": "lm-studio" } + ] + })) + }), + ); + let base = spawn_mock(app).await; + let mut config = lm_studio_config(&base); + config.local_ai.embedding_model_id = "bge-m3".to_string(); + + let prev_ollama_bin = std::env::var_os("OLLAMA_BIN"); + let fake_ollama = std::env::current_exe().expect("current test exe path"); + unsafe { + std::env::set_var("OLLAMA_BIN", &fake_ollama); + } + + let service = LocalAiService::new(&config); + let status = service.assets_status(&config).await.expect("assets status"); + + unsafe { + match prev_ollama_bin { + Some(value) => std::env::set_var("OLLAMA_BIN", value), + None => std::env::remove_var("OLLAMA_BIN"), + } + } + + assert_eq!(status.chat.provider, "lm_studio"); + assert_eq!(status.chat.state, "ready"); + assert_eq!(status.embedding.provider, "ollama"); + assert_eq!(status.embedding.path.as_deref(), Some("ollama://bge-m3")); + assert!(status + .embedding + .warning + .as_deref() + .unwrap_or("") + .contains("Ollama path")); +} + // ---- owned-PID lifecycle ------------------------------------------------ // // These tests pin the contract that `kill_ollama_server` only touches diff --git a/src/openhuman/local_ai/service/public_infer.rs b/src/openhuman/local_ai/service/public_infer.rs index dd865220d..41a3420f9 100644 --- a/src/openhuman/local_ai/service/public_infer.rs +++ b/src/openhuman/local_ai/service/public_infer.rs @@ -4,6 +4,7 @@ use crate::openhuman::local_ai::ollama_api::{ ns_to_tps, ollama_base_url, OllamaGenerateOptions, OllamaGenerateRequest, }; use crate::openhuman::local_ai::parse::sanitize_inline_completion; +use crate::openhuman::local_ai::provider::{provider_from_config, LocalAiProvider}; use super::LocalAiService; @@ -191,6 +192,45 @@ impl LocalAiService { // Multi-turn local chat is background LLM-bound work — gate it. let _gate_permit = crate::openhuman::scheduler_gate::wait_for_capacity().await; + if provider_from_config(config) == LocalAiProvider::LmStudio { + let started = std::time::Instant::now(); + let lm_messages = messages + .into_iter() + .map( + |message| crate::openhuman::local_ai::lm_studio_api::LmStudioChatMessage { + role: message.role, + content: message.content, + }, + ) + .collect(); + let outcome = self + .lm_studio_chat_completion( + config, + lm_messages, + max_tokens, + config.default_temperature as f32, + false, + ) + .await?; + let elapsed_ms = started.elapsed().as_millis() as u64; + { + let mut status = self.status.lock(); + status.state = "ready".to_string(); + status.last_latency_ms = Some(elapsed_ms); + status.prompt_toks_per_sec = None; + status.gen_toks_per_sec = None; + status.warning = None; + } + tracing::debug!( + elapsed_ms, + prompt_tokens = ?outcome.prompt_tokens, + completion_tokens = ?outcome.completion_tokens, + reply_len = outcome.reply.len(), + "[local_ai:chat] lm studio /v1/chat/completions done" + ); + return Ok(outcome.reply); + } + tracing::debug!( message_count = messages.len(), model = %crate::openhuman::local_ai::model_ids::effective_chat_model_id(config), @@ -416,6 +456,39 @@ impl LocalAiService { system.to_string() }; + if provider_from_config(config) == LocalAiProvider::LmStudio { + let messages = vec![ + crate::openhuman::local_ai::lm_studio_api::LmStudioChatMessage { + role: "system".to_string(), + content: effective_system, + }, + crate::openhuman::local_ai::lm_studio_api::LmStudioChatMessage { + role: "user".to_string(), + content: prompt.to_string(), + }, + ]; + let outcome = self + .lm_studio_chat_completion(config, messages, max_tokens, temperature, allow_empty) + .await?; + let elapsed_ms = started.elapsed().as_millis() as u64; + { + let mut status = self.status.lock(); + status.state = "ready".to_string(); + status.last_latency_ms = Some(elapsed_ms); + status.prompt_toks_per_sec = None; + status.gen_toks_per_sec = None; + status.warning = None; + } + tracing::debug!( + elapsed_ms, + prompt_tokens = ?outcome.prompt_tokens, + completion_tokens = ?outcome.completion_tokens, + reply_len = outcome.reply.len(), + "[local_ai:infer] lm studio /v1/chat/completions done" + ); + return Ok(outcome.reply); + } + let body = OllamaGenerateRequest { model: model_id, prompt: prompt.to_string(), diff --git a/src/openhuman/local_ai/service/public_infer_tests.rs b/src/openhuman/local_ai/service/public_infer_tests.rs index 97ccff727..20c95ae68 100644 --- a/src/openhuman/local_ai/service/public_infer_tests.rs +++ b/src/openhuman/local_ai/service/public_infer_tests.rs @@ -15,6 +15,16 @@ fn enabled_config() -> Config { config } +fn lm_studio_config(base: &str) -> Config { + let mut config = enabled_config(); + config.local_ai.provider = "lm_studio".to_string(); + config.local_ai.base_url = Some(format!("{base}/v1")); + config.local_ai.model_id = "local-model".to_string(); + config.local_ai.chat_model_id = "local-model".to_string(); + config.local_ai.opt_in_confirmed = true; + config +} + /// Build a LocalAiService pre-seeded to `ready` so inference calls skip /// `bootstrap()` and hit the HTTP path directly. fn ready_service(config: &Config) -> LocalAiService { @@ -123,6 +133,103 @@ async fn inference_errors_on_empty_response_when_allow_empty_false() { ); } +#[tokio::test] +async fn lm_studio_prompt_hits_openai_chat_completions() { + let _guard = crate::openhuman::local_ai::local_ai_test_guard(); + + let app = Router::new().route( + "/v1/chat/completions", + post(|Json(body): Json| async move { + assert_eq!(body["model"], "local-model"); + assert_eq!(body["stream"], false); + assert_eq!(body["max_tokens"], 16); + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!(body["messages"][1]["role"], "user"); + Json(json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "hello from lm studio" }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 7, "completion_tokens": 4, "total_tokens": 11 } + })) + }), + ); + let base = spawn_mock(app).await; + let config = lm_studio_config(&base); + let service = ready_service(&config); + + let reply = service + .prompt(&config, "hi", Some(16), true) + .await + .expect("lm studio prompt"); + + assert_eq!(reply, "hello from lm studio"); + let status = service.status(); + assert_eq!(status.provider, "lm_studio"); + assert_eq!(status.state, "ready"); +} + +#[tokio::test] +async fn lm_studio_chat_with_history_returns_response() { + let _guard = crate::openhuman::local_ai::local_ai_test_guard(); + + let app = Router::new().route( + "/v1/chat/completions", + post(|Json(body): Json| async move { + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!(body["messages"][1]["role"], "user"); + Json(json!({ + "choices": [{ + "message": { "role": "assistant", "content": "history reply" } + }] + })) + }), + ); + let base = spawn_mock(app).await; + let config = lm_studio_config(&base); + let service = ready_service(&config); + + let reply = service + .chat_with_history( + &config, + vec![ + crate::openhuman::local_ai::ollama_api::OllamaChatMessage { + role: "system".to_string(), + content: "be terse".to_string(), + }, + crate::openhuman::local_ai::ollama_api::OllamaChatMessage { + role: "user".to_string(), + content: "hi".to_string(), + }, + ], + None, + ) + .await + .expect("lm studio chat"); + + assert_eq!(reply, "history reply"); +} + +#[tokio::test] +async fn lm_studio_prompt_errors_on_non_success_status() { + let _guard = crate::openhuman::local_ai::local_ai_test_guard(); + + let app = Router::new().route( + "/v1/chat/completions", + post(|| async { (axum::http::StatusCode::BAD_GATEWAY, "not ready") }), + ); + let base = spawn_mock(app).await; + let config = lm_studio_config(&base); + let service = ready_service(&config); + + let err = service.prompt(&config, "hi", None, true).await.unwrap_err(); + + assert!(err.contains("lm studio chat failed with status 502")); +} + #[tokio::test] async fn summarize_disabled_returns_error() { // When local_ai is disabled the summarize fn should short-circuit. diff --git a/src/openhuman/local_ai/types.rs b/src/openhuman/local_ai/types.rs index cc5873ca1..5a814fbba 100644 --- a/src/openhuman/local_ai/types.rs +++ b/src/openhuman/local_ai/types.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use super::model_ids; use super::presets; +use super::provider::provider_from_config; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalAiStatus { @@ -43,6 +44,7 @@ pub struct LocalAiStatus { impl LocalAiStatus { pub(crate) fn disabled(config: &Config) -> Self { let vision_mode = presets::vision_mode_for_config(&config.local_ai); + let provider = provider_from_config(config); Self { state: "disabled".to_string(), model_id: model_ids::effective_chat_model_id(config), @@ -57,7 +59,7 @@ impl LocalAiStatus { embedding_state: "disabled".to_string(), stt_state: "disabled".to_string(), tts_state: "disabled".to_string(), - provider: "ollama".to_string(), + provider: provider.as_str().to_string(), download_progress: None, downloaded_bytes: None, total_bytes: None, @@ -67,7 +69,7 @@ impl LocalAiStatus { error_detail: None, error_category: None, model_path: None, - active_backend: "ollama".to_string(), + active_backend: provider.as_str().to_string(), backend_reason: None, last_latency_ms: None, prompt_toks_per_sec: None, @@ -171,6 +173,18 @@ mod tests { assert_eq!(status.active_backend, "ollama"); } + #[test] + fn disabled_status_reflects_lm_studio_provider() { + use crate::openhuman::local_ai::provider::LocalAiProvider; + + let mut config = Config::default(); + config.local_ai.provider = LocalAiProvider::LmStudio.as_str().to_string(); + let status = LocalAiStatus::disabled(&config); + + assert_eq!(status.provider, "lm_studio"); + assert_eq!(status.active_backend, "lm_studio"); + } + #[test] fn disabled_status_uses_config_vision_mode() { let mut config = Config::default(); diff --git a/src/openhuman/routing/factory.rs b/src/openhuman/routing/factory.rs index 58aa1662d..c03b6c3ec 100644 --- a/src/openhuman/routing/factory.rs +++ b/src/openhuman/routing/factory.rs @@ -2,7 +2,9 @@ use std::sync::Arc; use std::time::Duration; use crate::openhuman::config::LocalAiConfig; +use crate::openhuman::local_ai::lm_studio_api::lm_studio_base_url_from_local_ai; use crate::openhuman::local_ai::ollama_base_url; +use crate::openhuman::local_ai::provider::normalize_provider; use crate::openhuman::providers::compatible::{AuthStyle, OpenAiCompatibleProvider}; use crate::openhuman::providers::Provider; @@ -39,24 +41,52 @@ pub fn new_provider( .map(|s| s.trim().trim_end_matches('/').to_string()) .filter(|s| !s.is_empty()); + // Resolve the provider string: use the canonical helper for LM Studio + // aliases ("lm-studio", "lmstudio" → "lm_studio"), but preserve other + // provider strings ("llamacpp", "llama-server", "custom_openai") as-is so + // their own branches below still match. let provider_kind = local_ai_config.provider.trim().to_ascii_lowercase(); + let local_provider_kind: String = { + let normalized = normalize_provider(&provider_kind); + if normalized == "lm_studio" { + normalized + } else { + provider_kind.clone() + } + }; let use_openai_compat_local = override_base.is_some() || matches!( - provider_kind.as_str(), - "llamacpp" | "llama-server" | "custom_openai" + local_provider_kind.as_str(), + "lm_studio" | "llamacpp" | "llama-server" | "custom_openai" ); - let (provider_label, local_base, health) = if use_openai_compat_local { + let (provider_label, local_base, health) = if local_provider_kind == "lm_studio" { let base = override_base + .clone() + .unwrap_or_else(|| lm_studio_base_url_from_local_ai(local_ai_config)); + let probe = format!("{base}/models"); + tracing::debug!( + provider = %local_provider_kind, + base = %base, + "[routing] local inference configured via LM Studio" + ); + ( + "lm_studio", + base, + Arc::new(LocalHealthChecker::with_probe_url(probe, LOCAL_HEALTH_TTL)), + ) + } else if use_openai_compat_local { + let base = override_base + .clone() .or_else(|| local_ai_config.base_url.clone()) .unwrap_or_else(|| "http://127.0.0.1:8080/v1".to_string()); let probe = format!("{base}/models"); tracing::debug!( - provider = %provider_kind, + provider = %local_provider_kind, "[routing] local inference configured via OpenAI-compat (non-ollama)" ); ( - if provider_kind == "custom_openai" { + if local_provider_kind.as_str() == "custom_openai" { "custom_openai" } else { "llamacpp" @@ -187,6 +217,16 @@ mod tests { let _p = make_provider(&cfg); } + #[test] + fn factory_lm_studio_provider_constructs_without_panic() { + let mut cfg = LocalAiConfig::default(); + cfg.runtime_enabled = true; + cfg.provider = "lm-studio".to_string(); + cfg.base_url = Some("http://127.0.0.1:1234/v1".to_string()); + cfg.chat_model_id = "local-model".to_string(); + let _p = make_provider(&cfg); + } + #[test] fn factory_llama_server_alias_is_recognised() { // "llama-server" is an alias for the llamacpp OpenAI-compat path. diff --git a/src/openhuman/routing/provider.rs b/src/openhuman/routing/provider.rs index 79e69d56d..cfbf8043f 100644 --- a/src/openhuman/routing/provider.rs +++ b/src/openhuman/routing/provider.rs @@ -3,7 +3,7 @@ //! [`IntelligentRoutingProvider`] implements the [`Provider`] trait. On each call: //! //! 1. Classifies the `hint:*` model string → [`TaskCategory`]. -//! 2. Checks local Ollama health (cached, non-blocking). +//! 2. Checks selected local-provider health (cached, non-blocking). //! 3. Applies routing policy (task category + [`RoutingHints`]). //! 4. Calls the chosen provider; captures latency and token usage. //! 5. If local was chosen and: @@ -58,8 +58,9 @@ fn should_fallback( } } -/// Provider that routes requests between a local Ollama instance and the remote -/// OpenHuman backend based on task complexity, local health, and routing hints. +/// Provider that routes requests between a local provider instance and the +/// remote OpenHuman backend based on task complexity, local health, and +/// routing hints. pub struct IntelligentRoutingProvider { remote: Box, local: Box, diff --git a/src/openhuman/screen_intelligence/tests.rs b/src/openhuman/screen_intelligence/tests.rs index 8771e2aae..9d71c0bfe 100644 --- a/src/openhuman/screen_intelligence/tests.rs +++ b/src/openhuman/screen_intelligence/tests.rs @@ -1,5 +1,5 @@ use std::path::Path; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; use tokio::sync::Mutex; use tokio::time::{self, Duration}; @@ -46,13 +46,8 @@ impl Drop for EnvVarGuard { } } -static SCREEN_INTELLIGENCE_ENV_LOCK: OnceLock> = OnceLock::new(); - fn screen_intelligence_env_lock() -> std::sync::MutexGuard<'static, ()> { - match SCREEN_INTELLIGENCE_ENV_LOCK - .get_or_init(|| std::sync::Mutex::new(())) - .lock() - { + match crate::openhuman::config::TEST_ENV_LOCK.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), } diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 255e669d1..3824c7df7 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -2678,6 +2678,158 @@ async fn json_rpc_local_ai_device_profile_and_presets() { rpc_join.abort(); } +#[tokio::test] +async fn json_rpc_local_ai_lm_studio_config_diagnostics_and_prompt() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + let _tier_guard = EnvVarGuard::unset("OPENHUMAN_LOCAL_AI_TIER"); + let _lm_env_guard = EnvVarGuard::unset("OPENHUMAN_LM_STUDIO_BASE_URL"); + let _lm_alias_env_guard = EnvVarGuard::unset("LM_STUDIO_BASE_URL"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let lm_app = Router::new() + .route( + "/v1/models", + get(|| async { + Json(json!({ + "object": "list", + "data": [ + { "id": "local-model", "object": "model", "owned_by": "lm-studio" } + ] + })) + }), + ) + .route( + "/v1/chat/completions", + post(|Json(body): Json| async move { + assert_eq!( + body.get("model").and_then(Value::as_str), + Some("local-model") + ); + let roles: Vec<&str> = body + .get("messages") + .and_then(Value::as_array) + .map(|messages| { + messages + .iter() + .filter_map(|message| message.get("role").and_then(Value::as_str)) + .collect() + }) + .unwrap_or_default(); + assert_eq!(roles, vec!["system", "user"]); + Json(json!({ + "id": "chatcmpl-e2e", + "object": "chat.completion", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "hello from lm studio e2e" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 7, + "completion_tokens": 5, + "total_tokens": 12 + } + })) + }), + ); + let (lm_addr, lm_join) = serve_on_ephemeral(lm_app).await; + let lm_base = format!("http://{lm_addr}/v1"); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + let update = post_json_rpc( + &rpc_base, + 36, + "openhuman.config_update_local_ai_settings", + json!({ + "runtime_enabled": true, + "opt_in_confirmed": true, + "provider": "lm_studio", + "base_url": lm_base, + "model_id": "local-model", + "chat_model_id": "local-model" + }), + ) + .await; + let update_result = assert_no_jsonrpc_error(&update, "update_local_ai_settings"); + let config = update_result + .get("result") + .and_then(|value| value.get("config")) + .expect("config snapshot should be wrapped with logs"); + assert_eq!( + config + .get("local_ai") + .and_then(|local_ai| local_ai.get("provider")) + .and_then(Value::as_str), + Some("lm_studio") + ); + assert_eq!( + config + .get("local_ai") + .and_then(|local_ai| local_ai.get("opt_in_confirmed")) + .and_then(Value::as_bool), + Some(true) + ); + + let diagnostics = + post_json_rpc(&rpc_base, 37, "openhuman.local_ai_diagnostics", json!({})).await; + let diagnostics_result = assert_no_jsonrpc_error(&diagnostics, "lm_studio_diagnostics"); + assert_eq!( + diagnostics_result.get("provider").and_then(Value::as_str), + Some("lm_studio") + ); + assert_eq!( + diagnostics_result + .get("lm_studio_running") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + diagnostics_result + .get("expected") + .and_then(|expected| expected.get("chat_found")) + .and_then(Value::as_bool), + Some(true) + ); + + let prompt = post_json_rpc( + &rpc_base, + 38, + "openhuman.local_ai_prompt", + json!({ + "prompt": "hello", + "max_tokens": 16, + "no_think": true + }), + ) + .await; + let prompt_result = assert_no_jsonrpc_error(&prompt, "lm_studio_prompt"); + assert_eq!( + extract_string_outcome(prompt_result), + "hello from lm studio e2e" + ); + + lm_join.abort(); + mock_join.abort(); + rpc_join.abort(); +} + // ── Billing & Team E2E tests ────────────────────────────────────────────────── /// End-to-end test for billing RPC methods.