Skip to content
Open
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
139 changes: 139 additions & 0 deletions backend/workers/ollama_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""
Manage Ollama LLM models
"""
from backend.lib.worker import BasicWorker
from common.lib.ollama_client import OllamaClient


class OllamaManager(BasicWorker):
"""
Manage Ollama LLM models

Periodically refreshes the list of available models from an Ollama server.
Can also pull or delete models on demand when queued with a specific task.

Job details:
- task: "refresh" (default), "pull", or "delete"

Job remote_id:
- For refresh: "manage-ollama-refresh" (periodic) or "manage-ollama-manual" (on-demand)
- For pull/delete: the model name to pull or delete
"""
type = "manage-ollama"
max_workers = 1
client = None

@classmethod
def ensure_job(cls, config=None):
"""
Ensure the daily refresh job is always scheduled

:return: Job parameters for the worker
"""
return {"remote_id": "manage-ollama-refresh", "interval": 86400}

def work(self):
task = self.job.details.get("task", "refresh") if self.job.details else "refresh"
model_name = self.job.data["remote_id"]

self.client = self._get_client() # Initialize client once per job run
if not self.client.is_available():
self.job.finish()
return

if task == "refresh":
self.refresh_models()
elif task == "pull":
success = self.pull_model(model_name)
if success:
self.refresh_models()
elif task == "delete":
success = self.delete_model(model_name)
if success:
self.refresh_models()
else:
self.log.warning(f"OllamaManager: unknown task '{task}'")

self.job.finish()

def _get_client(self) -> OllamaClient:
"""Return a fresh OllamaClient configured from 4CAT settings."""
if not self.client:
self.client = OllamaClient.from_config(self.config, log=self.log)
return self.client

def refresh_models(self):
"""
Query the Ollama server for available models and update llm.available_models.
"""
if not self.config.get("llm.server", ""):
return

client = self._get_client()
models = client.list_models()

if not models and not self.config.get("llm.server", ""):
return

available_models = {}
for model in models:
model_id = model["name"]
meta = client.show_model(model_id)
if meta:
try:
display_name = OllamaClient.format_display_name(model_id, meta)
except Exception as e:
self.log.debug(f"OllamaManager: error formatting display name for {model_id}: {e}")
display_name = model_id
else:
self.log.debug(f"OllamaManager: could not get metadata for {model_id}, using name only")
display_name = model_id

available_models[model_id] = OllamaClient.build_model_entry(model_id, display_name, meta)

self.config.set("llm.available_models", available_models)
self.log.debug(f"OllamaManager: refreshed model list ({len(available_models)} models)")

# Reconcile enabled models: remove any that are no longer available
enabled_models = self.config.get("llm.enabled_models", [])
reconciled = [m for m in enabled_models if m in available_models]
if len(reconciled) != len(enabled_models):
removed = set(enabled_models) - set(reconciled)
self.log.info(f"OllamaManager: removed stale enabled model(s): {', '.join(removed)}")
self.config.set("llm.enabled_models", reconciled)

def pull_model(self, model_name):
"""
Pull a model from the Ollama registry.

:param str model_name: Model name (e.g. "llama3:8b")
:return bool: True on success
"""
if not self.config.get("llm.server", ""):
self.log.warning("OllamaManager: cannot pull model - no LLM server configured")
return False

success = self._get_client().pull_model(model_name)
if success:
self.log.info(f"OllamaManager: successfully pulled model '{model_name}'")
else:
self.log.warning(f"OllamaManager: could not pull model '{model_name}'")
return success

def delete_model(self, model_name):
"""
Delete a model from the Ollama server.

:param str model_name: Model name (e.g. "llama3:8b")
:return bool: True on success
"""
if not self.config.get("llm.server", ""):
self.log.warning("OllamaManager: cannot delete model - no LLM server configured")
return False

success = self._get_client().delete_model(model_name)
if success:
self.log.info(f"OllamaManager: successfully deleted model '{model_name}'")
else:
self.log.warning(f"OllamaManager: could not delete model '{model_name}'")
return success
70 changes: 9 additions & 61 deletions backend/workers/refresh_items.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,26 @@
"""
Refresh items
"""
import json

import requests

from backend.lib.worker import BasicWorker

class ItemUpdater(BasicWorker):
"""
Refresh 4CAT items

Refreshes settings that are dependent on external factors
Refreshes settings that are dependent on external factors.
LLM model refreshing is handled by the OllamaManager worker.
"""
type = "refresh-items"
max_workers = 1

@classmethod
def ensure_job(cls, config=None):
"""
Ensure that the refresher is always running

This is used to ensure that the refresher is always running, and if it is
not, it will be started by the WorkerManager.

:return: Job parameters for the worker
"""
return {"remote_id": "refresh-items", "interval": 60}
# ensure_job is intentionally disabled: this worker currently does nothing
# and would only create unnecessary job queue churn. Re-enable when work()
# has actual tasks to perform.
# @classmethod
# def ensure_job(cls, config=None):
# return {"remote_id": "refresh-items", "interval": 60}

def work(self):
# Refresh items
self.refresh_settings()

# Placeholder – no tasks implemented yet.
self.job.finish()

def refresh_settings(self):
"""
Refresh settings
"""
# LLM server settings
llm_provider = self.config.get("llm.provider_type", "none").lower()
llm_server = self.config.get("llm.server", "")

# For now we only support the Ollama API
if llm_provider == "ollama" and llm_server:
headers = {"Content-Type": "application/json"}
llm_api_key = self.config.get("llm.api_key", "")
llm_auth_type = self.config.get("llm.auth_type", "")
if llm_api_key and llm_auth_type:
headers[llm_auth_type] = llm_api_key

available_models = {}
try:
response = requests.get(f"{llm_server}/api/tags", headers=headers, timeout=10)
if response.status_code == 200:
settings = response.json()
for model in settings.get("models", []):
model = model["name"]
try:
model_metadata = requests.post(f"{llm_server}/api/show", headers=headers, json={"model": model}, timeout=10).json()
available_models[model] = {
"name": f"{model_metadata['model_info']['general.basename']} ({model_metadata['details']['parameter_size']} parameters)",
"model_card": f"https://ollama.com/library/{model}",
"provider": "local"
}

except (requests.RequestException, json.JSONDecodeError, KeyError) as e:
self.log.debug(f"Could not get metadata for model {model} from Ollama - skipping (error: {e})")

self.config.set("llm.available_models", available_models)
self.log.debug("Refreshed LLM server settings cache")
else:
self.log.warning(f"Could not refresh LLM server settings cache - server returned status code {response.status_code}")

except requests.RequestException as e:
self.log.warning(f"Could not refresh LLM server settings cache - request error: {str(e)}")

Comment on lines 23 to 26
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ItemUpdater worker now has an empty work() method but still schedules itself every 60 seconds via ensure_job. This means a job is created and claimed every minute to do absolutely nothing. If this worker exists solely as a placeholder for future use, it should either be removed or the scheduling interval should be reduced. If it's no longer needed, consider removing the worker entirely to avoid unnecessary job queue churn.

Copilot uses AI. Check for mistakes.
10 changes: 9 additions & 1 deletion common/lib/config_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,15 @@
"type": UserInput.OPTION_TEXT_JSON,
"default": {},
"help": "Available LLM models",
"tooltip": "A JSON dictionary of available LLM models on the server. 4CAT will query the LLM server for available models periodically.",
"tooltip": "A JSON dictionary of available LLM models on the server. Refreshed daily by the OllamaManager worker.",
"indirect": True,
"global": True
},
"llm.enabled_models": {
"type": UserInput.OPTION_TEXT_JSON,
"default": [],
"help": "Enabled LLM models",
"tooltip": "List of model keys enabled for use. Managed via the LLM Server settings panel.",
"indirect": True,
"global": True
},
Expand Down
Loading
Loading