From 20555c802a9ff14dec31ddd206e375701e49dbc3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 25 Aug 2026 19:17:59 +1000 Subject: [PATCH] feat: expose Ollama models in Hermes picker --- README.md | 6 +++ __init__.py | 8 ++++ dashboard/manifest.json | 2 +- dashboard/plugin_api.py | 20 +++++++++ ollama_provider.py | 91 +++++++++++++++++++++++++++++++++++++++++ plugin.yaml | 2 +- 6 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 __init__.py create mode 100644 ollama_provider.py diff --git a/README.md b/README.md index 43784de..42c4d42 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ The Available downloads controls support: Popularity and date ordering use upstream metadata only; the plugin does not invent popularity, dates, RAM requirements, or token metrics. +## Hermes native model selector + +During plugin load after install/update, the plugin registers the active Ollama endpoint(s) in Hermes' native `providers:` configuration as **Ollama Models (Local)** and, when configured, **Ollama Models (Remote)**. Hermes discovers the installed IDs through each endpoint's OpenAI-compatible `/v1/models` route, so models such as `gemma4:latest` become selectable with their exact Ollama tags. + +Changing the Hermes model selector only changes the selected provider/model. It does not call Ollama load or keep-alive APIs. End users must use the Ollama Models page's **Load selected permanently** action when they want a model loaded and kept available. Installing/updating the plugin and synchronizing providers does not change Hermes' existing default model or provider. + ## Installation This plugin is installable from the Hermes dashboard Plugin Section using the repository URL: diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d06631c --- /dev/null +++ b/__init__.py @@ -0,0 +1,8 @@ +"""Hermes Ollama Models plugin integration.""" + +from .ollama_provider import sync_hermes_ollama_providers + + +def register(ctx) -> None: + """Register the Ollama endpoints in Hermes' native model picker.""" + sync_hermes_ollama_providers() diff --git a/dashboard/manifest.json b/dashboard/manifest.json index f512e77..d36cd0c 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -3,7 +3,7 @@ "label": "Ollama Models", "description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.", "icon": "Cpu", - "version": "1.5.10", + "version": "1.5.11", "tab": {"path": "/ollama-manager", "position": "after:models"}, "entry": "dist/index.js", "css": "dist/style.css", diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 14d8964..fdefd98 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import binascii import io +import importlib.util import ipaddress import json import mimetypes @@ -311,6 +312,22 @@ def _write_connections(value: dict[str, str]) -> None: pass +def _sync_native_ollama_providers(connections: dict[str, str] | None = None) -> None: + """Keep Hermes' native model picker aligned with this plugin's endpoints.""" + try: + helper_path = Path(__file__).resolve().parent.parent / "ollama_provider.py" + spec = importlib.util.spec_from_file_location("_ollama_manager_provider_sync", helper_path) + if spec is None or spec.loader is None: + return + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.sync_hermes_ollama_providers(connections) + except Exception: + # Provider synchronization must never make the Ollama dashboard route fail. + return + + + def _apply_saved_connection() -> None: global LOCAL_OLLAMA saved = _read_connections().get("active_url") @@ -1511,6 +1528,7 @@ def connections_configure(body: ConnectionRequest) -> dict[str, Any]: if role == "local": saved["active_url"] = url _write_connections(saved) + _sync_native_ollama_providers(saved) if role == "local": global LOCAL_OLLAMA LOCAL_OLLAMA = url @@ -1533,6 +1551,7 @@ def connections_delete(role: str) -> dict[str, Any]: if role == "local": saved["active_url"] = "" _write_connections(saved) + _sync_native_ollama_providers(saved) if role == "local": _apply_saved_connection() return {"ok": True, "role": role, "removed_url": removed, "message": f"Removed saved {role} Ollama endpoint"} @@ -1687,4 +1706,5 @@ def delete_model(body: ModelRequest) -> dict[str, Any]: def create_ollama_routes(app) -> None: + _sync_native_ollama_providers() app.include_router(router, prefix="/api/plugins/ollama-manager") diff --git a/ollama_provider.py b/ollama_provider.py new file mode 100644 index 0000000..0074faa --- /dev/null +++ b/ollama_provider.py @@ -0,0 +1,91 @@ +"""Synchronize Ollama Models endpoints into Hermes' native model picker.""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Any + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + +LOCAL_PROVIDER_KEY = "ollama-manager-local" +REMOTE_PROVIDER_KEY = "ollama-manager-remote" +_OWNED_PROVIDER_KEYS = {LOCAL_PROVIDER_KEY, REMOTE_PROVIDER_KEY} + + +def _running_in_container() -> bool: + return Path("/.dockerenv").exists() or Path("/run/.containerenv").exists() + + +def _default_endpoint() -> str: + configured = os.environ.get("OLLAMA_HOST", "").strip().rstrip("/") + if configured: + return configured + return "http://ollama:11434" if _running_in_container() else "http://localhost:11434" + + +def _connections() -> dict[str, str]: + path = get_hermes_home() / "ollama-manager" / "connections.json" + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + value = {} + if not isinstance(value, dict): + value = {} + return { + key: str(value.get(key) or "").strip().rstrip("/") + for key in ("active_url", "local_url", "remote_url") + } + + +def _endpoint_to_api(endpoint: str) -> str: + endpoint = str(endpoint or "").strip().rstrip("/") + if endpoint.endswith("/v1"): + return endpoint + return endpoint + "/v1" + + +def _provider_entry(name: str, endpoint: str) -> dict[str, Any]: + return { + "name": name, + "api": _endpoint_to_api(endpoint), + "transport": "chat_completions", + "discover_models": True, + } + + +def sync_hermes_ollama_providers(connections: dict[str, str] | None = None) -> bool: + """Upsert the plugin's local/remote providers without changing defaults.""" + try: + from hermes_cli.config import load_config, save_config + + saved = connections or _connections() + local = saved.get("local_url") or saved.get("active_url") or _default_endpoint() + remote = saved.get("remote_url") + if remote and remote == local: + remote = "" + + config = load_config() + providers = config.get("providers") + if not isinstance(providers, dict): + providers = {} + config["providers"] = providers + + for key in _OWNED_PROVIDER_KEYS: + providers.pop(key, None) + + providers[LOCAL_PROVIDER_KEY] = _provider_entry("Ollama Models (Local)", local) + if remote: + providers[REMOTE_PROVIDER_KEY] = _provider_entry("Ollama Models (Remote)", remote) + + # The provider is selectable, not the active default. Do not touch + # model.default or model.provider while installing/updating the plugin. + save_config(config, strip_defaults=False) + return True + except Exception as exc: # provider registration must never break the dashboard + logger.warning("Could not synchronize Ollama providers into Hermes config: %s", exc) + return False diff --git a/plugin.yaml b/plugin.yaml index 30da538..d27dc79 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: ollama-manager -version: 1.5.10 +version: 1.5.11 description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry. auto_install_dependencies: true python_dependencies: