feat: switch LLM to OpenWebUI with live model selection
Rebuild & Deploy / rebuild (push) Successful in 3m18s

- Point the agent at OpenWebUI's OpenAI-compatible API (LLM_BASE_URL,
  LLM_API_KEY, LLM_MODEL) with GEMMA_* env fallbacks
- Add list_available_models() fetching {base_url}/models, filtering
  non-chat ids (embedding/reranker)
- Rename GemmaLLM -> HopeLLM with update_options(model=...) for runtime
  model switching (mutates _opts.model) and instance-based compaction
- Data channel: agent publishes {type: models} after connect and on
  get_models; set_model switches live and acks model_changed
- Web UI: Brain section in the settings sheet with a model picker that
  persists the choice in localStorage
- Update tests, CI workflow env, and docs for the new endpoint
This commit is contained in:
snowspeeder committed 2026-08-29 12:09:03 -04:00
1 parent 024c42ffb1
commit 360e3276b0
14 files changed
+334 -76

No files matched your search

+3 -3
View File
@@ -9,7 +9,7 @@ tool servers, and agent process.
- The container is up: `docker compose up -d` (and healthy)
- Host deps installed: `pip install -r tests/requirements.txt`
(or `uv pip install -r tests/requirements.txt`)
- The LLM at `GEMMA_BASE_URL` (default `http://192.168.86.2:8023/v1`) is reachable
- The LLM at `LLM_BASE_URL` (default `http://192.168.86.2:3005/api`, OpenWebUI) is reachable
- Internet access for the weather tool tests (wttr.in)
## Run
@@ -25,10 +25,10 @@ python -m pytest tests/ -v -m "not slow" # skip LLM/network-heavy tests
| File | Target | Notes |
|------|--------|-------|
| `test_web_ui.py` | nginx HTTPS UI (:9178) + token endpoint (:8091) | static assets, `/token` (direct + via nginx), `/livekit/` proxy |
| `test_llm_api.py` | Gemma LLM API directly | completion, tool calling, `enable_thinking=false` latency, streaming |
| `test_llm_api.py` | LLM API directly | completion, tool calling, `enable_thinking=false` latency, streaming |
| `test_mcp_tools.py` | MCP servers via `docker exec` | weather/time, authenticated remote memory save/recall/delete, and local skills — probes are cleaned up |
| `test_agent_integration.py` | running container | agent process alive, worker registered with LiveKit, all 4 supervised processes up, container health, LiveKit HTTP API |
| `test_compaction.py` | `GemmaLLM._compact_context` in-container | compaction triggers >24 items, system prompt preserved, short context untouched |
| `test_compaction.py` | `HopeLLM._compact_context` in-container | compaction triggers >24 items, system prompt preserved, short context untouched |
## Notes
+21 -5
View File
@@ -3,7 +3,7 @@
Tests run on the HOST machine against the live "voice" container:
- HTTPS web UI -> https://localhost:9178 (self-signed cert, verify=False)
- Token server -> http://127.0.0.1:8091 (host network mode)
- LLM API -> GEMMA_BASE_URL (default http://192.168.86.2:8023/v1)
- LLM API -> LLM_BASE_URL (default http://192.168.86.2:3005/api)
- In-container -> docker exec voice ...
"""
@@ -18,8 +18,21 @@ import pytest
CONTAINER = "voice"
WEB_BASE_URL = os.environ.get("VOICE_WEB_URL", "https://localhost:9178")
TOKEN_URL = os.environ.get("VOICE_TOKEN_URL", "http://127.0.0.1:8091/token")
GEMMA_BASE_URL = os.environ.get("GEMMA_BASE_URL", "http://192.168.86.2:8023/v1").rstrip("/")
GEMMA_MODEL = os.environ.get("GEMMA_MODEL", "gemma-4-e4b")
LLM_BASE_URL = (
os.environ.get("LLM_BASE_URL")
or os.environ.get("GEMMA_BASE_URL")
or "http://192.168.86.2:3005/api"
).rstrip("/")
LLM_MODEL = (
os.environ.get("LLM_MODEL")
or os.environ.get("GEMMA_MODEL")
or "gemma-4-e4b"
)
LLM_API_KEY = (
os.environ.get("LLM_API_KEY")
or os.environ.get("GEMMA_API_KEY")
or "not-needed"
)
_users = json.loads((Path(__file__).parents[1] / "config" / "users.json").read_text())
_shane_memory = _users["users"]["shane"]["memory"]
MEMORY_MCP_URL = os.environ.get("MEMORY_MCP_URL", _shane_memory["url"])
@@ -88,8 +101,11 @@ def token() -> dict:
@pytest.fixture(scope="session")
def llm_client():
"""httpx client pointed at the Gemma LLM API (same one Hope uses)."""
with httpx.Client(base_url=GEMMA_BASE_URL, timeout=60) as c:
"""httpx client pointed at the LLM API (same one Hope uses)."""
headers = {}
if LLM_API_KEY and LLM_API_KEY != "not-needed":
headers["Authorization"] = f"Bearer {LLM_API_KEY}"
with httpx.Client(base_url=LLM_BASE_URL, headers=headers, timeout=60) as c:
yield c
+12 -11
View File
@@ -1,26 +1,27 @@
"""Tests for GemmaLLM context compaction (agent.GemmaLLM._compact_context).
"""Tests for HopeLLM context compaction (agent.HopeLLM._compact_context).
Compaction runs a real side LLM call to the xNAS, so these are marked slow.
The agent module is imported inside the container via docker exec.
Compaction runs a real side LLM call to the configured endpoint, so these
are marked slow. The agent module is imported inside the container via
docker exec.
"""
import pytest
from conftest import GEMMA_BASE_URL, GEMMA_MODEL, docker_exec_python
from conftest import LLM_BASE_URL, LLM_MODEL, docker_exec_python
# {num_messages} and {do_chat} are filled per test; all other braces are literal.
TEMPLATE = '''
import logging, sys
import logging, os, sys
from livekit.agents import llm as lk_llm
sys.path.insert(0, "/opt/voice-agent")
from agent import GemmaLLM
from agent import HopeLLM
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
llm = GemmaLLM(
llm = HopeLLM(
model="{model}",
base_url="{base_url}",
api_key="not-needed",
api_key=os.environ.get("LLM_API_KEY", "not-needed"),
max_completion_tokens=100,
extra_body={{"chat_template_kwargs": {{"enable_thinking": False}}}},
)
@@ -44,7 +45,7 @@ async def main():
print("AFTER items=" + str(len(compacted.items)))
for it in compacted.items:
role = getattr(it, "role", None)
content = GemmaLLM._msg_content_text(it)
content = HopeLLM._msg_content_text(it)
print("ITEM " + str(role) + ": " + content[:120])
{do_chat}
@@ -57,8 +58,8 @@ CHAT_SNIPPET = ' stream = llm.chat(chat_ctx=compacted)\n collected = await
def _run(num_messages: int, *, do_chat: bool = False) -> str:
code = TEMPLATE.format(
model=GEMMA_MODEL,
base_url=GEMMA_BASE_URL,
model=LLM_MODEL,
base_url=LLM_BASE_URL,
num_messages=num_messages,
do_chat=CHAT_SNIPPET if do_chat else "pass",
)
+3 -3
View File
@@ -1,10 +1,10 @@
"""Tests for the Gemma LLM API — the exact endpoint Hope's agent uses."""
"""Tests for the LLM API — the exact endpoint Hope's agent uses."""
import time
import pytest
from conftest import GEMMA_BASE_URL, GEMMA_MODEL
from conftest import LLM_BASE_URL, LLM_MODEL
WEATHER_TOOL = {
"type": "function",
@@ -22,7 +22,7 @@ WEATHER_TOOL = {
def _chat(llm_client, messages, *, tools=None, stream=False, max_tokens=200):
payload = {
"model": GEMMA_MODEL,
"model": LLM_MODEL,
"messages": messages,
"max_tokens": max_tokens,
"temperature": 0.3,