Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion api/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
from typing import Optional

import httpx
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field

from services import llm_server
from services import ollama_health as ollama_health_service
from services.llm_server import llama_pool
from services.mesh_ops import MeshOp, MeshOpNotFoundError, MeshOpsRegistry, mesh_ops_registry

Expand Down Expand Up @@ -572,6 +573,17 @@ async def list_external_models(req: ExternalModelsRequest):
return {"models": []}


@router.get("/ollama/health")
async def ollama_health(base_url: str = "http://localhost:11434/v1", model: str = ""):
"""Is Ollama up, and is `model` pulled? `status` is `ok`, `unreachable`,
`model_missing` or `no_model`; the chat panel turns it into a banner.
Only local addresses are accepted (400 otherwise)."""
try:
return await ollama_health_service.check(base_url, model)
except ValueError as e:
raise HTTPException(400, str(e))


async def _unload_llm_after_workflow(request: AgentChatRequest, actions_done: list[ActionDone]) -> None:
"""Free the local LLM's VRAM once a workflow has been dispatched, so the
workflow gets the full GPU. Best-effort — never fail the chat over it."""
Expand Down
80 changes: 80 additions & 0 deletions api/services/ollama_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Ollama health check for the chat agent: is the server reachable, and is the
configured model pulled? Answers in a structured status the UI turns into an
actionable message ("ollama serve" / "ollama pull <model>").
"""
from urllib.parse import urlsplit

import httpx

# Short: this runs when the chat panel opens, and a down Ollama must not stall it.
TIMEOUT_S = 3.0

# The API answers any origin (CORS *), so a web page could call this endpoint.
# Only local addresses are probed, and nothing of the upstream answer is echoed,
# so it can't be used to map the user's network.
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}


def is_local_url(url: str) -> bool:
try:
parts = urlsplit(url)
host = parts.hostname
except ValueError:
return False
return (
parts.scheme in ("http", "https")
and not parts.username
and not parts.password
and host in LOOPBACK_HOSTS
)


def native_base_url(base_url: str) -> str:
"""The chat goes through Ollama's OpenAI-compatible API under `/v1`; the
native API (`/api/tags`) lives at the server root."""
url = base_url.strip().rstrip("/")
if url.endswith("/v1"):
url = url[: -len("/v1")]
return url


def _with_tag(name: str) -> str:
# Ollama resolves an untagged name to `:latest`.
return name if ":" in name else f"{name}:latest"


async def check(base_url: str, model: str) -> dict:
"""Raises ValueError when `base_url` is not a local http(s) address."""
root = native_base_url(base_url)
if not is_local_url(root):
raise ValueError("base_url must be a local http(s) address (localhost, 127.0.0.1 or ::1)")
model = model.strip()
result: dict = {"status": "ok", "base_url": root, "model": model}
not_ollama = {**result, "status": "unreachable", "detail": "not an Ollama server"}

try:
async with httpx.AsyncClient(timeout=TIMEOUT_S, follow_redirects=False) as client:
r = await client.get(f"{root}/api/tags")
if r.status_code != 200:
return not_ollama
entries = r.json().get("models", [])
except httpx.TimeoutException:
return {**result, "status": "unreachable", "detail": "timed out"}
except httpx.HTTPError:
return {**result, "status": "unreachable", "detail": "connection refused"}
except (ValueError, AttributeError):
return not_ollama

available = sorted({
str(m.get("name") or m.get("model"))
for m in entries
if isinstance(m, dict) and (m.get("name") or m.get("model"))
})
result["available"] = available

if not model:
return {**result, "status": "no_model"}
if _with_tag(model) not in {_with_tag(n) for n in available}:
return {**result, "status": "model_missing"}
return result
178 changes: 178 additions & 0 deletions api/tests/test_agent_ollama_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import asyncio
import unittest
from unittest import mock

import httpx
from fastapi import HTTPException

import routers.agent as agent
from services import ollama_health


class _MockClientFactory:
"""Real AsyncClients wired to a MockTransport, so the health check talks to a
fake Ollama instead of the network."""

def __init__(self, handler) -> None:
self._handler = handler
self._real = httpx.AsyncClient
self.kwargs: list[dict] = []

def __call__(self, *args, **kwargs):
self.kwargs.append(dict(kwargs))
kwargs["transport"] = httpx.MockTransport(self._handler)
return self._real(*args, **kwargs)


def _tags(*names: str) -> dict:
return {"models": [{"name": n, "model": n} for n in names]}


def _check(handler, base_url: str = "http://localhost:11434/v1", model: str = "qwen2.5:3b"):
factory = _MockClientFactory(handler)
with mock.patch.object(ollama_health.httpx, "AsyncClient", factory):
result = asyncio.run(agent.ollama_health(base_url=base_url, model=model))
return result, factory


class OllamaHealthTests(unittest.TestCase):
def test_ok_when_reachable_and_model_pulled(self) -> None:
requests = []

def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json=_tags("llama3:8b", "qwen2.5:3b"))

result, _ = _check(handler)

self.assertEqual(result["status"], "ok")
self.assertEqual(result["model"], "qwen2.5:3b")
# The native API lives at the root, not under the OpenAI-compatible /v1.
self.assertEqual(str(requests[0].url), "http://localhost:11434/api/tags")

def test_uses_the_configured_base_url(self) -> None:
requests = []

def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json=_tags("qwen2.5:3b"))

result, _ = _check(handler, base_url="http://127.0.0.1:9999/v1/")

self.assertEqual(result["status"], "ok")
self.assertEqual(str(requests[0].url), "http://127.0.0.1:9999/api/tags")
self.assertEqual(result["base_url"], "http://127.0.0.1:9999")

def test_rejects_urls_that_are_not_local(self) -> None:
# The API answers any origin (CORS *): a web page must not be able to use
# this endpoint to probe the user's network.
requests = []

def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json=_tags("qwen2.5:3b"))

for url in (
"http://192.168.1.20:11434/v1",
"http://example.com/v1",
"http://user:pass@localhost:11434/v1",
"file:///etc/passwd",
"ftp://localhost/v1",
"http://localhost.evil.com:11434",
):
with self.assertRaises(HTTPException, msg=url) as ctx:
_check(handler, base_url=url)
self.assertEqual(ctx.exception.status_code, 400, url)
self.assertEqual(requests, [])

def test_accepts_loopback_hosts(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_tags("qwen2.5:3b"))

for url in ("http://localhost:11434/v1", "http://127.0.0.1:11434", "http://[::1]:11434/v1"):
result, _ = _check(handler, base_url=url)
self.assertEqual(result["status"], "ok", url)

def test_model_missing_when_not_pulled(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_tags("llama3:8b"))

result, _ = _check(handler)

self.assertEqual(result["status"], "model_missing")
self.assertEqual(result["model"], "qwen2.5:3b")
self.assertEqual(result["available"], ["llama3:8b"])

def test_untagged_model_matches_latest(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_tags("mistral:latest"))

result, _ = _check(handler, model="mistral")

self.assertEqual(result["status"], "ok")

def test_tagged_model_does_not_match_a_different_tag(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_tags("qwen2.5:latest"))

result, _ = _check(handler, model="qwen2.5:3b")

self.assertEqual(result["status"], "model_missing")

def test_no_model_configured(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_tags("llama3:8b"))

result, _ = _check(handler, model=" ")

self.assertEqual(result["status"], "no_model")

def test_unreachable_on_connection_error(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("[Errno 111] internal detail", request=request)

result, _ = _check(handler)

self.assertEqual(result["status"], "unreachable")
self.assertEqual(result["base_url"], "http://localhost:11434")
self.assertEqual(result["detail"], "connection refused")

def test_unreachable_on_timeout(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("timed out", request=request)

result, _ = _check(handler)

self.assertEqual(result["status"], "unreachable")

def test_unreachable_when_something_else_answers(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, text="secret upstream body")

result, _ = _check(handler)

self.assertEqual(result["status"], "unreachable")
# Generic: nothing of the upstream answer is echoed back to the caller.
self.assertEqual(result["detail"], "not an Ollama server")

def test_unreachable_on_non_json_body(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="<html>hello</html>")

result, _ = _check(handler)

self.assertEqual(result["status"], "unreachable")

def test_uses_a_short_timeout(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_tags("qwen2.5:3b"))

_, factory = _check(handler)

timeout = factory.kwargs[0].get("timeout")
self.assertIsNotNone(timeout)
self.assertLessEqual(float(timeout), 5.0)


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"prepare-resources": "node scripts/download-python-embed.js",
"test": "npm run test:py && npm run test:node",
"test:py": "node scripts/run-pytests.mjs",
"test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs",
"test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/utils/ollamaHealth.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs",
"package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder",
"package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64",
"lint": "eslint ."
Expand Down
23 changes: 23 additions & 0 deletions src/areas/generate/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { useExtensionsStore } from '@shared/stores/extensionsStore'
import { useWorkflowRunStore } from '@areas/workflows/workflowRunStore'
import { buildAllWorkflowExtensions } from '@areas/workflows/mockExtensions'
import { readAgentEvents, toolStopTitle } from '@shared/utils/agentStream'
import { adviceText, ollamaAdvice } from '@shared/utils/ollamaHealth'
import OllamaBanner, { useOllamaHealth } from './OllamaBanner'

// ─── Types ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -302,6 +304,9 @@ export default function ChatPanel(): JSX.Element {
const pushMeshUrl = useAppStore((s) => s.pushMeshUrl)
const undoMesh = useAppStore((s) => s.undoMesh)

const isOllama = provider === 'ollama'
const ollama = useOllamaHealth(isOllama, apiUrl, providerBaseUrl(provider, externalConfig), model)

const workflows = useWorkflowsStore((s) => s.workflows)
const saveWorkflow = useWorkflowsStore((s) => s.save)
const setActiveWorkflow = useWorkflowsStore((s) => s.setActive)
Expand Down Expand Up @@ -374,6 +379,7 @@ export default function ChatPanel(): JSX.Element {
setError(null)
const id = `a-${Date.now()}`
let finished = false
let failure: string | null = null
try {
const context = { ...buildContext(), ...extraContext }

Expand Down Expand Up @@ -440,6 +446,8 @@ export default function ChatPanel(): JSX.Element {
break
case 'done':
finished = true
// A guardrail stop means the LLM answered: nothing to ask Ollama.
if (event.error && !event.stopped) failure = event.message
patch((m) => ({
...m,
// An error comes after whatever text had already streamed; a
Expand All @@ -457,6 +465,7 @@ export default function ChatPanel(): JSX.Element {
}
}
if (!finished) setError('The response was interrupted.')
if (failure !== null && isOllama) await explainOllamaFailure(id, failure)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setError(msg.includes('fetch') ? 'Cannot reach Modly API. Is the backend running?' : msg)
Expand All @@ -472,6 +481,17 @@ export default function ChatPanel(): JSX.Element {
}
}

// A failed Ollama turn: re-ping it (refreshing the banner) and, when it's
// down or the model isn't pulled, say so instead of the raw provider error.
async function explainOllamaFailure(id: string, rawError: string) {
const health = await ollama.check()
const advice = health ? ollamaAdvice(health) : null
if (!advice) return
setMessages((prev) => prev.map((m) => (m.id === id
? { ...m, content: m.content.replace(rawError, adviceText(advice)) }
: m)))
}

async function applyToolPayload(payload: AgentActionPayload | null) {
if (payload?.type === 'mesh_update' && payload.url) {
updateCurrentJob({ outputUrl: payload.url })
Expand Down Expand Up @@ -663,6 +683,9 @@ export default function ChatPanel(): JSX.Element {
</div>
</div>

{/* Ollama down / model not pulled */}
<OllamaBanner health={ollama.health} checking={ollama.checking} onRetry={() => { void ollama.check() }} />

{/* Input bar */}
<div className="shrink-0 px-3 pb-3 pt-2 border-t border-zinc-800">
<input
Expand Down
Loading
Loading