commit 4b782ffdc8e84891aec94914ce45c1a9554ca94d Author: Luca Sacchi Ricciardi Date: Fri Apr 24 19:11:58 2026 +0200 docs: add comprehensive README and project scaffolding - README completo con istruzioni di installazione, configurazione e utilizzo - API Swagger/OpenAPI documentata - File env.example con variabili di configurazione - Dockerfile multi-stage ottimizzato - Docker Compose con Ollama e LLM Monitor - Struttura completa dell'app FastAPI (main.py, config, api routes) - Servizio client Ollama reusabile - Dashboard web HTML con TailwindCSS - Test suite con pytest - Makefile per comandi comuni - CONTRIBUTING.md per i contributori - LICENSE MIT - .editorconfig e .dockerignore - requirements.txt e requirements-dev.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5a57a45 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +*.egg +.pytest_cache/ +.mypy_cache/ +.coverage +.venv/ +venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +.gitignore +Thumbs.db + +# Git +.git/ +.gitignore + +# Documentation +docs/ +*.md +LICENSE +CONTRIBUTING.md + +# Development +node_modules/ +package-lock.json +Makefile +.env* + +# Test +tests/ +pytest.ini diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9e87143 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +# Python files +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 100 + +# JSON files +[*.json] +indent_style = space +indent_size = 2 + +# YAML files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# Markdown files +[*.md] +trim_trailing_whitespace = false + +# Dockerfile +[Dockerfile*] +indent_style = space +indent_size = 4 diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..3344bd8 --- /dev/null +++ b/.env.local @@ -0,0 +1,15 @@ +# LLM Monitor - Local Development Environment +# Copia questo file da env.example e personalizza per il tuo ambiente + +OLLAMA_HOST=http://localhost:11434 +OLLAMA_TIMEOUT=30 + +API_HOST=0.0.0.0 +API_PORT=8000 +API_WORKERS=1 + +CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000 + +LOG_LEVEL=DEBUG + +ENVIRONMENT=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4017968 --- /dev/null +++ b/.gitignore @@ -0,0 +1,145 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Node modules (per TailwindCSS) +node_modules/ +package-lock.json + +# Build outputs +app/web/static/css/output.css + +# Database +*.db +*.sqlite +*.sqlite3 + +# Uploads +uploads/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..25a33ea --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,123 @@ +# Contribuire a LLM Monitor + +Grazie per l'interesse nel contribuire a LLM Monitor! Questo documento fornisce linee guida per contribuire al progetto. + +## Codice di Condotta + +Questo progetto aderisce a un Codice di Condotta per garantire un ambiente inclusivo e rispettoso. + +## Come Contribuire + +### Segnalare Bug + +- **Verificare prima** se il bug non è già stato segnalato +- **Includere dettagli**: sistema operativo, versione Python, stack trace +- **Fornire un esempio ripetibile** se possibile + +### Suggerire Miglioramenti + +- **Verificare prima** se il suggerimento non è già stato fatto +- **Spiegare chiaramente** il caso d'uso e i benefici +- **Fornire esempi** di come dovrebbe funzionare + +### Pull Requests + +1. **Fork il repository** +2. **Crea un branch**: `git checkout -b feature/my-feature` +3. **Installa le dipendenze di sviluppo**: + ```bash + pip install -r requirements-dev.txt + ``` +4. **Effettua i tuoi cambiamenti** seguendo lo [Style Guide](#style-guide) +5. **Scrivi i test**: I test sono obbligatori per nuove funzionalità +6. **Esegui i test**: `make test` +7. **Formatta il codice**: `make format` +8. **Esegui il linting**: `make lint` +9. **Fai il commit**: `git commit -m "feat: descrizione della feature"` +10. **Push**: `git push origin feature/my-feature` +11. **Apri una PR** descrivendo i cambiamenti + +## Style Guide + +### Python + +- Usa **Black** per la formattazione: `make format` +- Usa **isort** per l'organizzazione degli import +- Segui **PEP 8** +- Usa type hints per le funzioni nuove +- Documenta con docstring (formato Google): + +```python +def my_function(param1: str, param2: int) -> bool: + """ + Descrizione breve della funzione. + + Args: + param1: Descrizione del primo parametro + param2: Descrizione del secondo parametro + + Returns: + Descrizione del valore ritornato + + Raises: + ValueError: Quando succede + """ + pass +``` + +### Commit Messages + +Usa il formato Conventional Commits: +- `feat: aggiungi nuova feature` +- `fix: correggi un bug` +- `docs: aggiorna documentazione` +- `style: formattazione, without semantic change` +- `refactor: ristruttura codice` +- `perf: migliora le performance` +- `test: aggiungi o modifica test` +- `chore: aggiorna dipendenze, etc` + +Esempio: +``` +feat: aggiungi endpoint per ottenere statistiche modelli + +- Nuovo endpoint GET /api/v1/models/stats +- Ritorna conteggio, spazio totale e ultimi aggiornamenti +- Include test di integrazione +``` + +### Codice + +- Mantieni le funzioni piccole e ben definite +- Usa nomi descrittivi +- Aggiungi commenti per la logica complessa +- Evita magic numbers, usa costanti + +## Testing + +- Tutti i PR devono includere test per nuove funzionalità +- La copertura del codice deve rimanere ≥ 80% +- Esegui i test prima di submitare: + ```bash + make test + ``` + +## Documentazione + +- Aggiorna il README se cambi il comportamento +- Aggiungi docstring a nuove funzioni +- Aggiorna il CHANGELOG.md + +## Processo di Review + +- I PR saranno reviewati il prima possibile +- I feedback saranno forniti in buona fede +- Le discussioni devono essere costruttive + +## Licenza + +Contribuendo, accetti che i tuoi contributi siano licensiati sotto la MIT License. + +--- + +Domande? Apri un issue o contatta il maintainer! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..16776fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# Multi-stage build per LLM Monitor + +# Stage 1: Builder +FROM python:3.11-slim as builder + +WORKDIR /app + +# Installare dipendenze di build +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copiare requirements +COPY requirements.txt . + +# Installare Python packages in un virtualenv +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \ + pip install --no-cache-dir -r requirements.txt + +# Stage 2: Runtime +FROM python:3.11-slim + +WORKDIR /app + +# Installare dipendenze di runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copiare il virtualenv dal builder +COPY --from=builder /opt/venv /opt/venv + +# Copiare codice dell'app +COPY app/ /app/app/ +COPY main.py /app/ +COPY .env* /app/ + +# Impostare PATH +ENV PATH="/opt/venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 + +# Esporre la porta +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/api/v1/health || exit 1 + +# Comando di avvio +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0be13e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 Luca Sacchi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9a38188 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +.PHONY: help install dev prod test lint format clean docker-build docker-up docker-down + +help: + @echo "LLM Monitor - Makefile Commands" + @echo "================================" + @echo "make install - Installa le dipendenze" + @echo "make dev - Avvia in modalità sviluppo" + @echo "make prod - Avvia in modalità produzione" + @echo "make test - Esegui i test" + @echo "make lint - Linting e type checking" + @echo "make format - Formatta il codice" + @echo "make clean - Pulisce cache e file temporanei" + @echo "make docker-build - Build dell'immagine Docker" + @echo "make docker-up - Avvia i container con Docker Compose" + @echo "make docker-down - Ferma i container con Docker Compose" + +install: + python3 -m venv venv + . venv/bin/activate && pip install -r requirements.txt -r requirements-dev.txt + +dev: + . venv/bin/activate && uvicorn main:app --reload --host 0.0.0.0 --port 8000 + +prod: + . venv/bin/activate && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 + +test: + . venv/bin/activate && pytest tests/ -v --cov=app + +lint: + . venv/bin/activate && flake8 app/ tests/ main.py && mypy app/ + +format: + . venv/bin/activate && black app/ tests/ main.py && isort app/ tests/ main.py + +clean: + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete + rm -rf .pytest_cache .mypy_cache .coverage htmlcov dist build *.egg-info + +docker-build: + docker build -t llm-monitor:latest . + +docker-up: + docker compose up -d + +docker-down: + docker compose down + +docker-logs: + docker compose logs -f llm-monitor + +docker-shell: + docker compose exec llm-monitor /bin/bash diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd4890c --- /dev/null +++ b/README.md @@ -0,0 +1,381 @@ +# LLM Monitor - Dashboard Ollama + +Una dashboard web moderna e intuitiva per monitorare e gestire i modelli LLM caricati in **Ollama**. Visualizza i modelli disponibili, i dettagli dei caricamenti e accedi ai dati via API Ollama direttamente da una web app elegante. + +## 🎯 Caratteristiche + +- ✨ **Dashboard intuitiva** - Visualizza in tempo reale i modelli caricati in Ollama +- 📊 **Monitoraggio modelli** - Dettagli completi di ogni modello (nome, dimensione, memoria, stato) +- 🔌 **API REST documentata** - Documentazione interattiva con Swagger/OpenAPI +- 🎨 **UI moderna** - Interfaccia elegante realizzata con TailwindCSS +- 🐳 **Docker ready** - Container sempre acceso (until stopped) +- ⚡ **Performance** - Costruito su FastAPI e uVicorn +- 🔐 **Configurazione flessibile** - File `.env` per personalizzazione + +## 📋 Requisiti + +- **Python** 3.10+ +- **Ollama** installato e in esecuzione +- **Docker** (opzionale, per containerizzazione) +- **Docker Compose** (opzionale) + +## 🚀 Installazione Rapida + +### 1. Clonare il repository + +```bash +git clone https://github.com/LucaSacchiNet/llm-monitor.git +cd llm-monitor +``` + +### 2. Configurare l'ambiente + +Copia il file di esempio: + +```bash +cp env.example .env +``` + +Modifica `.env` con i tuoi parametri (vedi [Configurazione](#configurazione)): + +```bash +nano .env +``` + +### 3. Installare le dipendenze + +#### Opzione A: Ambiente virtuale (Sviluppo) + +```bash +python3 -m venv venv +source venv/bin/activate # Su Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +#### Opzione B: Docker (Produzione) + +```bash +docker compose up -d +``` + +### 4. Avviare l'applicazione + +#### Modalità sviluppo + +```bash +python3 -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +#### Modalità produzione + +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +## ⚙️ Configurazione + +Crea un file `.env` nella root del progetto (copia da `env.example`): + +```env +# Ollama Configuration +OLLAMA_HOST=http://localhost:11434 +OLLAMA_TIMEOUT=30 + +# API Configuration +API_HOST=0.0.0.0 +API_PORT=8000 +API_WORKERS=4 + +# CORS Configuration +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# Logging +LOG_LEVEL=INFO + +# Environment +ENVIRONMENT=development +``` + +### Variabili disponibili + +| Variabile | Default | Descrizione | +|-----------|---------|-------------| +| `OLLAMA_HOST` | `http://localhost:11434` | URL della API Ollama | +| `OLLAMA_TIMEOUT` | `30` | Timeout (secondi) per le richieste | +| `API_HOST` | `0.0.0.0` | Host su cui esporre l'API | +| `API_PORT` | `8000` | Porta dell'API | +| `API_WORKERS` | `4` | Worker processes | +| `CORS_ORIGINS` | `http://localhost:3000` | Origini CORS consentite | +| `LOG_LEVEL` | `INFO` | Livello di logging | +| `ENVIRONMENT` | `development` | Ambiente (development/production) | + +## 📚 API Swagger + +La documentazione interattiva dell'API è disponibile automaticamente: + +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc + +### Endpoint principali + +#### Recuperare modelli caricati + +```bash +GET /api/v1/models +``` + +**Risposta:** + +```json +{ + "models": [ + { + "name": "llama2", + "digest": "abc123...", + "size": 3825922048, + "modified_at": "2024-01-15T10:30:00Z" + } + ] +} +``` + +#### Dettagli di un modello specifico + +```bash +GET /api/v1/models/{model_name} +``` + +#### Health check API Ollama + +```bash +GET /api/v1/health +``` + +**Risposta:** + +```json +{ + "status": "healthy", + "ollama_version": "0.1.0", + "uptime_seconds": 3600 +} +``` + +### Test API con cURL + +```bash +# Ottenere i modelli +curl http://localhost:8000/api/v1/models + +# Ottenere info su un modello +curl http://localhost:8000/api/v1/models/llama2 + +# Health check +curl http://localhost:8000/api/v1/health +``` + +## 🐳 Docker + +### Build dell'immagine + +```bash +docker build -t llm-monitor:latest . +``` + +### Eseguire il container + +```bash +docker run -d \ + --name llm-monitor \ + -p 8000:8000 \ + --env-file .env \ + --network host \ + llm-monitor:latest +``` + +> ⚠️ **Nota**: `--network host` consente al container di accedere a Ollama su localhost + +### Docker Compose + +Usa il file `docker-compose.yml` fornito: + +```bash +# Avviare i servizi +docker compose up -d + +# Visualizzare i log +docker compose logs -f llm-monitor + +# Fermare i servizi +docker compose down + +# Riavviare +docker compose restart llm-monitor +``` + +### Container sempre acceso + +Il container Ollama rimarrà in esecuzione fino al suo arresto manuale: + +```bash +# Fermare +docker compose stop ollama +# oppure +docker stop llm-monitor + +# Riavviare +docker compose start ollama +# oppure +docker start llm-monitor +``` + +## 📁 Struttura del Progetto + +``` +llm-monitor/ +├── main.py # Entry point dell'applicazione +├── requirements.txt # Dipendenze Python +├── env.example # Esempio di configurazione +├── Dockerfile # Configurazione Docker +├── docker-compose.yml # Composizione servizi +├── README.md # Questo file +├── .gitignore +│ +├── app/ +│ ├── __init__.py +│ ├── config.py # Configurazione (variabili ambiente) +│ ├── main.py # Inizializzazione FastAPI +│ │ +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── models.py # Endpoint modelli +│ │ ├── health.py # Endpoint health +│ │ └── v1/ +│ │ └── __init__.py +│ │ +│ ├── services/ +│ │ ├── __init__.py +│ │ ├── ollama.py # Client Ollama +│ │ └── cache.py # Cache in-memory (opzionale) +│ │ +│ └── web/ +│ ├── __init__.py +│ ├── static/ # Assets statici (CSS compilato TailwindCSS) +│ └── templates/ # Template HTML +│ +└── tests/ + ├── __init__.py + ├── test_api.py + └── test_ollama.py +``` + +## 🛠️ Sviluppo + +### Setup locale + +```bash +# Clonare il repo +git clone https://github.com/LucaSacchiNet/llm-monitor.git +cd llm-monitor + +# Ambiente virtuale +python3 -m venv venv +source venv/bin/activate + +# Installare dipendenze + dev +pip install -r requirements.txt +pip install -r requirements-dev.txt # black, pytest, flake8, etc. +``` + +### Comandi utili + +```bash +# Formattare codice +black app/ tests/ main.py + +# Linting +flake8 app/ tests/ main.py + +# Test +pytest tests/ -v + +# Test con coverage +pytest tests/ --cov=app + +# Hot reload durante sviluppo +uvicorn main:app --reload +``` + +### Compilare TailwindCSS + +```bash +# Installare dipendenze Node (opzionale) +npm install + +# Generare CSS in modalità développement +npm run tailwind:dev + +# Build per produzione +npm run tailwind:build +``` + +## 🐛 Troubleshooting + +### Errore: "Cannot connect to Ollama" + +- Verificare che Ollama sia in esecuzione: `curl http://localhost:11434/api/tags` +- Controllare che l'indirizzo in `.env` sia corretto (`OLLAMA_HOST`) +- Se usi Docker, assicurati che il container abbia accesso a Ollama (vedi [Docker](#docker)) + +### Errore: "Port 8000 already in use" + +```bash +# Cambiare la porta in .env +API_PORT=8001 + +# Oppure liberare la porta +lsof -ti :8000 | xargs kill -9 +``` + +### Dashboard lenta + +- Verificare lo stato di Ollama +- Aumentare `OLLAMA_TIMEOUT` in `.env` +- Controllare i log: `docker compose logs -f llm-monitor` + +## 📄 Dipendenze Principali + +- **FastAPI** - Framework web moderno +- **uVicorn** - Server ASGI ad alte prestazioni +- **Pydantic** - Validazione dati +- **Requests** - Client HTTP +- **Jinja2** - Template HTML +- **TailwindCSS** - Utility-first CSS + +## 📜 Licenza + +Questo progetto è distribuito sotto licenza **MIT**. Vedi il file `LICENSE` per dettagli. + +## 🤝 Contribuire + +Le pull request sono benvenute! Per cambiamenti importanti, apri prima un issue per discutere i cambiamenti proposti. + +### Processo di contribuzione + +1. Fork il repository +2. Crea un branch (`git checkout -b feature/amazing-feature`) +3. Commit i cambiamenti (`git commit -m 'Add amazing feature'`) +4. Push al branch (`git push origin feature/amazing-feature`) +5. Apri una Pull Request + +## 📞 Supporto + +Per domande o segnalazioni di bug, apri un **Issue** nel repository. + +--- + +**Fatto con ❤️ da [LucaSacchi.Net](https://lucasacchi.net)** + +**Versione**: 1.0.0 +**Ultima modifica**: Aprile 2026 +**Status**: 🟢 In Development diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..0c5d18b --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,5 @@ +""" +LLM Monitor - Package principale +""" + +__version__ = "1.0.0" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..3b99881 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,3 @@ +""" +API routes +""" diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..319a470 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,70 @@ +""" +Health check endpoints +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from datetime import datetime +import requests +import logging +from app.config import settings + +logger = logging.getLogger(__name__) +router = APIRouter() + +class HealthResponse(BaseModel): + status: str + ollama_status: str + timestamp: datetime + + class Config: + json_schema_extra = { + "example": { + "status": "healthy", + "ollama_status": "online", + "timestamp": "2024-01-15T10:30:00Z" + } + } + +@router.get("/health", response_model=HealthResponse) +async def health_check(): + """ + Health check dell'API e dello stato di Ollama + + Returns: + HealthResponse: Status dell'API e di Ollama + """ + try: + # Check Ollama + response = requests.get( + f"{settings.OLLAMA_HOST}/api/tags", + timeout=settings.OLLAMA_TIMEOUT + ) + ollama_status = "online" if response.status_code == 200 else "offline" + except Exception as e: + logger.warning(f"Ollama health check failed: {e}") + ollama_status = "offline" + + return HealthResponse( + status="healthy", + ollama_status=ollama_status, + timestamp=datetime.utcnow() + ) + +@router.get("/ready") +async def ready(): + """ + Readiness probe per Kubernetes/Docker + """ + try: + response = requests.get( + f"{settings.OLLAMA_HOST}/api/tags", + timeout=5 + ) + if response.status_code == 200: + return {"status": "ready"} + else: + raise HTTPException(status_code=503, detail="Service unavailable") + except Exception as e: + logger.error(f"Readiness check failed: {e}") + raise HTTPException(status_code=503, detail="Service unavailable") diff --git a/app/api/models.py b/app/api/models.py new file mode 100644 index 0000000..71e5bfa --- /dev/null +++ b/app/api/models.py @@ -0,0 +1,232 @@ +""" +Models endpoints - Gestione dei modelli Ollama +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime +import requests +import logging +from app.config import settings + +logger = logging.getLogger(__name__) +router = APIRouter() + +class ModelInfo(BaseModel): + """Informazioni su un modello""" + name: str + digest: str + size: int + modified_at: datetime + + class Config: + json_schema_extra = { + "example": { + "name": "llama2", + "digest": "abc123def456...", + "size": 3825922048, + "modified_at": "2024-01-15T10:30:00Z" + } + } + +class ModelsResponse(BaseModel): + """Risposta con lista di modelli""" + models: List[ModelInfo] + total: int + + class Config: + json_schema_extra = { + "example": { + "models": [ + { + "name": "llama2", + "digest": "abc123def456...", + "size": 3825922048, + "modified_at": "2024-01-15T10:30:00Z" + } + ], + "total": 1 + } + } + +@router.get("/models", response_model=ModelsResponse) +async def get_models(): + """ + Recupera l'elenco di tutti i modelli caricati in Ollama + + Returns: + ModelsResponse: Lista dei modelli disponibili + + Raises: + HTTPException: Se Ollama non è disponibile + """ + try: + response = requests.get( + f"{settings.OLLAMA_HOST}/api/tags", + timeout=settings.OLLAMA_TIMEOUT + ) + + if response.status_code != 200: + raise HTTPException( + status_code=502, + detail="Ollama non disponibile" + ) + + data = response.json() + models_data = data.get("models", []) + + models = [ + ModelInfo( + name=model.get("name", "unknown"), + digest=model.get("digest", ""), + size=model.get("size", 0), + modified_at=datetime.fromisoformat( + model.get("modified_at", "").replace("Z", "+00:00") + ) if model.get("modified_at") else datetime.utcnow() + ) + for model in models_data + ] + + return ModelsResponse( + models=models, + total=len(models) + ) + + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Timeout: Ollama non ha risposto in tempo" + ) + except requests.exceptions.ConnectionError: + raise HTTPException( + status_code=502, + detail="Impossible connettersi a Ollama" + ) + except Exception as e: + logger.error(f"Error fetching models: {e}") + raise HTTPException( + status_code=500, + detail="Errore nel recupero dei modelli" + ) + +@router.get("/models/{model_name}", response_model=ModelInfo) +async def get_model(model_name: str): + """ + Recupera le informazioni di un modello specifico + + Args: + model_name: Nome del modello da cercare + + Returns: + ModelInfo: Informazioni del modello + + Raises: + HTTPException: Se il modello non esiste o Ollama non è disponibile + """ + try: + response = requests.get( + f"{settings.OLLAMA_HOST}/api/tags", + timeout=settings.OLLAMA_TIMEOUT + ) + + if response.status_code != 200: + raise HTTPException( + status_code=502, + detail="Ollama non disponibile" + ) + + data = response.json() + models_data = data.get("models", []) + + # Cercare il modello + for model in models_data: + if model.get("name") == model_name: + return ModelInfo( + name=model.get("name", "unknown"), + digest=model.get("digest", ""), + size=model.get("size", 0), + modified_at=datetime.fromisoformat( + model.get("modified_at", "").replace("Z", "+00:00") + ) if model.get("modified_at") else datetime.utcnow() + ) + + raise HTTPException( + status_code=404, + detail=f"Modello '{model_name}' non trovato" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error fetching model: {e}") + raise HTTPException( + status_code=500, + detail="Errore nel recupero del modello" + ) + +@router.post("/models/{model_name}/pull") +async def pull_model(model_name: str): + """ + Scarica/carica un modello in Ollama + + Args: + model_name: Nome del modello da scaricare + + Returns: + dict: Status del download + """ + try: + response = requests.post( + f"{settings.OLLAMA_HOST}/api/pull", + json={"name": model_name}, + timeout=None # Pull può essere lungo + ) + + if response.status_code not in [200, 201]: + raise HTTPException( + status_code=502, + detail="Errore nel pull del modello" + ) + + return {"status": "pulling", "model": model_name} + + except Exception as e: + logger.error(f"Error pulling model: {e}") + raise HTTPException( + status_code=500, + detail="Errore nel pull del modello" + ) + +@router.delete("/models/{model_name}") +async def delete_model(model_name: str): + """ + Elimina un modello da Ollama + + Args: + model_name: Nome del modello da eliminare + + Returns: + dict: Confirmazione eliminazione + """ + try: + response = requests.delete( + f"{settings.OLLAMA_HOST}/api/delete", + json={"name": model_name}, + timeout=settings.OLLAMA_TIMEOUT + ) + + if response.status_code not in [200, 204]: + raise HTTPException( + status_code=502, + detail="Errore nell'eliminazione del modello" + ) + + return {"status": "deleted", "model": model_name} + + except Exception as e: + logger.error(f"Error deleting model: {e}") + raise HTTPException( + status_code=500, + detail="Errore nell'eliminazione del modello" + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..a0e29be --- /dev/null +++ b/app/config.py @@ -0,0 +1,34 @@ +""" +Configurazione dell'applicazione tramite variabili di ambiente +""" + +from pydantic_settings import BaseSettings +from typing import List + +class Settings(BaseSettings): + """Configurazione dell'applicazione""" + + # Ollama + OLLAMA_HOST: str = "http://localhost:11434" + OLLAMA_TIMEOUT: int = 30 + + # API + API_HOST: str = "0.0.0.0" + API_PORT: int = 8000 + API_WORKERS: int = 4 + + # CORS + CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000" + + # Logging + LOG_LEVEL: str = "INFO" + + # Environment + ENVIRONMENT: str = "development" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + +# Istanza globale della configurazione +settings = Settings() diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..06e2eb5 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,3 @@ +""" +Services - Business logic +""" diff --git a/app/services/ollama.py b/app/services/ollama.py new file mode 100644 index 0000000..e937eae --- /dev/null +++ b/app/services/ollama.py @@ -0,0 +1,116 @@ +""" +Ollama client service +""" + +import requests +import logging +from typing import List, Dict, Optional +from app.config import settings + +logger = logging.getLogger(__name__) + +class OllamaClient: + """Client per interagire con l'API Ollama""" + + def __init__(self, host: str = None, timeout: int = None): + self.host = host or settings.OLLAMA_HOST + self.timeout = timeout or settings.OLLAMA_TIMEOUT + + def get_models(self) -> List[Dict]: + """ + Recupera l'elenco dei modelli da Ollama + + Returns: + List[Dict]: Lista dei modelli + """ + try: + response = requests.get( + f"{self.host}/api/tags", + timeout=self.timeout + ) + response.raise_for_status() + return response.json().get("models", []) + except Exception as e: + logger.error(f"Error getting models from Ollama: {e}") + return [] + + def get_model(self, model_name: str) -> Optional[Dict]: + """ + Recupera informazioni su un modello specifico + + Args: + model_name: Nome del modello + + Returns: + Dict: Informazioni del modello, o None se non trovato + """ + try: + models = self.get_models() + for model in models: + if model.get("name") == model_name: + return model + return None + except Exception as e: + logger.error(f"Error getting model {model_name}: {e}") + return None + + def is_available(self) -> bool: + """ + Verifica se Ollama è disponibile + + Returns: + bool: True se disponibile, False altrimenti + """ + try: + response = requests.get( + f"{self.host}/api/tags", + timeout=5 + ) + return response.status_code == 200 + except Exception: + return False + + def pull_model(self, model_name: str) -> bool: + """ + Scarica/carica un modello + + Args: + model_name: Nome del modello + + Returns: + bool: True se ha successo + """ + try: + response = requests.post( + f"{self.host}/api/pull", + json={"name": model_name}, + timeout=None + ) + return response.status_code in [200, 201] + except Exception as e: + logger.error(f"Error pulling model {model_name}: {e}") + return False + + def delete_model(self, model_name: str) -> bool: + """ + Elimina un modello + + Args: + model_name: Nome del modello + + Returns: + bool: True se ha successo + """ + try: + response = requests.delete( + f"{self.host}/api/delete", + json={"name": model_name}, + timeout=self.timeout + ) + return response.status_code in [200, 204] + except Exception as e: + logger.error(f"Error deleting model {model_name}: {e}") + return False + +# Istanza globale del client Ollama +ollama_client = OllamaClient() diff --git a/app/web/__init__.py b/app/web/__init__.py new file mode 100644 index 0000000..08221a1 --- /dev/null +++ b/app/web/__init__.py @@ -0,0 +1,3 @@ +""" +Web templates and static files +""" diff --git a/app/web/templates/index.html b/app/web/templates/index.html new file mode 100644 index 0000000..cdff492 --- /dev/null +++ b/app/web/templates/index.html @@ -0,0 +1,224 @@ + + + + + + LLM Monitor - Dashboard Ollama + + + + +
+ +
+
+
+
+
+ 🦙 +
+

LLM Monitor

+
+
+
+
+ Controllo... +
+
+
+
+
+ + +
+
+ +
+
+
Modelli Caricati
+
-
+
+
+
Spazio Totale
+
-
+
+
+
Status Ollama
+
-
+
+
+ + +
+
+

Modelli Disponibili

+ +
+ + +
+
+
+

Caricamento modelli...

+
+
+
+ + +
+

📚 Documentazione API

+

La API è documentata e testabile direttamente da:

+ +
+
+
+ + + +
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cd207b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +version: '3.8' + +services: + # Ollama Service + ollama: + image: ollama/ollama:latest + container_name: ollama-server + ports: + - "11434:11434" + environment: + OLLAMA_HOST: 0.0.0.0:11434 + volumes: + - ollama_data:/root/.ollama + restart: unless-stopped + # Keep container running until stopped + stdin_open: true + tty: true + networks: + - llm-monitor-network + + # LLM Monitor Dashboard + llm-monitor: + build: + context: . + dockerfile: Dockerfile + container_name: llm-monitor-app + ports: + - "8000:8000" + environment: + # Carica variabili da .env + OLLAMA_HOST: http://ollama:11434 + OLLAMA_TIMEOUT: 30 + API_HOST: 0.0.0.0 + API_PORT: 8000 + API_WORKERS: 4 + CORS_ORIGINS: http://localhost:3000,http://localhost:5173,http://localhost:8000 + LOG_LEVEL: INFO + ENVIRONMENT: production + env_file: + - .env + depends_on: + - ollama + restart: unless-stopped + stdin_open: true + tty: true + networks: + - llm-monitor-network + # Health check + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +volumes: + ollama_data: + driver: local + +networks: + llm-monitor-network: + driver: bridge + +# Istruzioni di avvio: +# docker compose up -d # Avvia i servizi +# docker compose logs -f # Visualizza i log +# docker compose down # Ferma i servizi +# docker compose stop ollama # Ferma solo Ollama +# docker compose start ollama # Riavvia Ollama diff --git a/env.example b/env.example new file mode 100644 index 0000000..7a80a22 --- /dev/null +++ b/env.example @@ -0,0 +1,52 @@ +# LLM Monitor - Environment Configuration Example +# Copy this file to .env and adjust values for your environment + +# =========================================== +# Ollama Configuration +# =========================================== +# URL base dell'API Ollama +OLLAMA_HOST=http://localhost:11434 + +# Timeout per le richieste a Ollama (secondi) +OLLAMA_TIMEOUT=30 + +# =========================================== +# API Configuration +# =========================================== +# Host su cui esporre l'API +API_HOST=0.0.0.0 + +# Porta su cui esporre l'API +API_PORT=8000 + +# Numero di worker processes per uVicorn +API_WORKERS=4 + +# =========================================== +# CORS Configuration +# =========================================== +# Origini CORS consentite (separare con virgola) +CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000 + +# =========================================== +# Logging +# =========================================== +# Livello di logging (DEBUG, INFO, WARNING, ERROR, CRITICAL) +LOG_LEVEL=INFO + +# =========================================== +# Environment +# =========================================== +# Ambiente di esecuzione (development, production) +ENVIRONMENT=development + +# =========================================== +# Security (opzionale) +# =========================================== +# Secret key per sessioni (genera con: python -c "import secrets; print(secrets.token_hex(32))") +# SECRET_KEY=your-secret-key-here + +# =========================================== +# Database (se necessario in futuro) +# =========================================== +# DATABASE_URL=sqlite:///./llm-monitor.db diff --git a/main.py b/main.py new file mode 100644 index 0000000..9f5e921 --- /dev/null +++ b/main.py @@ -0,0 +1,82 @@ +""" +LLM Monitor - Dashboard per controllare i modelli caricati in Ollama +Entry point dell'applicazione FastAPI +""" + +import logging +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from fastapi.middleware.cors import CORSMiddleware +from pathlib import Path +import os + +# Configurazione logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Importare le rotte +from app.api.health import router as health_router +from app.api.models import router as models_router +from app.config import settings + +# Creare l'app FastAPI +app = FastAPI( + title="LLM Monitor API", + description="Dashboard per il monitoraggio dei modelli LLM in Ollama", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json" +) + +# Configurare CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS.split(","), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Registrare le rotte API +app.include_router(health_router, prefix="/api/v1", tags=["health"]) +app.include_router(models_router, prefix="/api/v1", tags=["models"]) + +# Servire i file statici +static_path = Path(__file__).parent / "app" / "web" / "static" +if static_path.exists(): + app.mount("/static", StaticFiles(directory=static_path), name="static") + +# Servire la dashboard web +templates_path = Path(__file__).parent / "app" / "web" / "templates" + +@app.get("/") +async def root(): + """Redirect alla dashboard""" + return FileResponse(templates_path / "index.html") + +@app.get("/dashboard") +async def dashboard(): + """Dashboard principale""" + return FileResponse(templates_path / "index.html") + +# Event hooks +@app.on_event("startup") +async def startup_event(): + logger.info("🚀 LLM Monitor avviato") + logger.info(f"📊 Ollama host: {settings.OLLAMA_HOST}") + +@app.on_event("shutdown") +async def shutdown_event(): + logger.info("🛑 LLM Monitor arrestato") + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host=settings.API_HOST, + port=settings.API_PORT, + reload=settings.ENVIRONMENT == "development", + log_level=settings.LOG_LEVEL.lower() + ) diff --git a/package.json b/package.json new file mode 100644 index 0000000..a5688ed --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "llm-monitor", + "version": "1.0.0", + "description": "Dashboard per controllare i modelli caricati in Ollama", + "private": true, + "scripts": { + "tailwind:dev": "tailwindcss -i app/web/static/css/input.css -o app/web/static/css/output.css --watch", + "tailwind:build": "tailwindcss -i app/web/static/css/input.css -o app/web/static/css/output.css --minify" + }, + "devDependencies": { + "tailwindcss": "^3.4.0" + } +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7cd2740 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,26 @@ +# Development Dependencies + +# Testing +pytest==7.4.3 +pytest-cov==4.1.0 +pytest-asyncio==0.21.1 + +# Code Quality +black==23.12.0 +flake8==6.1.0 +isort==5.13.2 +mypy==1.7.1 + +# Linting +pylint==3.0.3 + +# Documentation +mkdocs==1.5.3 +mkdocs-material==9.5.0 + +# Debug +ipython==8.18.1 +ipdb==0.13.13 + +# Pre-commit hooks +pre-commit==3.5.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af5c64c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,34 @@ +# LLM Monitor Requirements + +# Core Web Framework +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 + +# HTTP Client +requests==2.31.0 +httpx==0.25.1 + +# Template Engine +jinja2==3.1.2 + +# Database & ORM (opzionale) +# sqlalchemy==2.0.23 +# alembic==1.12.1 + +# Utilities +python-dotenv==1.0.0 +python-multipart==0.0.6 + +# Async +aiohttp==3.9.1 + +# Logging & Monitoring +python-json-logger==2.0.7 + +# CORS +fastapi-cors==0.0.6 + +# API Documentation +# (Swagger/ReDoc sono inclusi di default in FastAPI) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c06d60b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Test suite for LLM Monitor +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a08f3f2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +""" +Pytest configuration and fixtures +""" + +import pytest +from fastapi.testclient import TestClient +from main import app + +@pytest.fixture +def client(): + """FastAPI test client""" + return TestClient(app) + +@pytest.fixture +def mock_models_response(): + """Mock response from Ollama API""" + return { + "models": [ + { + "name": "llama2", + "digest": "91ab89b1b9117e34fb2ff4a5bff07b2e1fa1f1d2d3e4f5a6b7c8d9e0f1a2b3c", + "size": 3825922048, + "modified_at": "2024-01-15T10:30:00.000Z" + }, + { + "name": "mistral", + "digest": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", + "size": 4096000000, + "modified_at": "2024-01-14T15:45:00.000Z" + } + ] + } diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..17d50d5 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,94 @@ +""" +Test API endpoints +""" + +import pytest +from unittest.mock import patch, MagicMock + +def test_health_check(client): + """Test health endpoint""" + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + + response = client.get("/api/v1/health") + assert response.status_code == 200 + data = response.json() + assert "status" in data + assert data["status"] == "healthy" + +def test_ready_endpoint(client): + """Test readiness probe""" + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + + response = client.get("/api/v1/ready") + assert response.status_code == 200 + assert response.json() == {"status": "ready"} + +def test_get_models(client, mock_models_response): + """Test getting models list""" + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_models_response + mock_get.return_value = mock_response + + response = client.get("/api/v1/models") + assert response.status_code == 200 + data = response.json() + assert "models" in data + assert "total" in data + assert data["total"] == 2 + assert len(data["models"]) == 2 + assert data["models"][0]["name"] == "llama2" + +def test_get_models_ollama_offline(client): + """Test getting models when Ollama is offline""" + with patch("requests.get") as mock_get: + mock_get.side_effect = Exception("Connection refused") + + response = client.get("/api/v1/models") + assert response.status_code == 500 + +def test_get_specific_model(client, mock_models_response): + """Test getting specific model""" + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_models_response + mock_get.return_value = mock_response + + response = client.get("/api/v1/models/llama2") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "llama2" + +def test_get_nonexistent_model(client, mock_models_response): + """Test getting nonexistent model""" + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_models_response + mock_get.return_value = mock_response + + response = client.get("/api/v1/models/nonexistent") + assert response.status_code == 404 + +def test_root_endpoint(client): + """Test root endpoint redirects to dashboard""" + response = client.get("/", follow_redirects=False) + assert response.status_code in [200, 307] + +def test_openapi_schema(client): + """Test OpenAPI schema is available""" + response = client.get("/openapi.json") + assert response.status_code == 200 + schema = response.json() + assert "info" in schema + assert "paths" in schema + assert "/api/v1/health" in schema["paths"] + assert "/api/v1/models" in schema["paths"] diff --git a/tests/test_ollama.py b/tests/test_ollama.py new file mode 100644 index 0000000..a6c6f34 --- /dev/null +++ b/tests/test_ollama.py @@ -0,0 +1,122 @@ +""" +Test Ollama client service +""" + +import pytest +from unittest.mock import patch, MagicMock +from app.services.ollama import OllamaClient + +@pytest.fixture +def ollama_client(): + """Create OllamaClient instance""" + return OllamaClient(host="http://localhost:11434", timeout=30) + +def test_get_models(ollama_client): + """Test getting models from Ollama""" + mock_data = { + "models": [ + {"name": "llama2", "digest": "abc123", "size": 3825922048}, + {"name": "mistral", "digest": "def456", "size": 4096000000} + ] + } + + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_data + mock_get.return_value = mock_response + + models = ollama_client.get_models() + assert len(models) == 2 + assert models[0]["name"] == "llama2" + +def test_get_models_error(ollama_client): + """Test get models when error occurs""" + with patch("requests.get") as mock_get: + mock_get.side_effect = Exception("Connection error") + + models = ollama_client.get_models() + assert models == [] + +def test_get_model(ollama_client): + """Test getting specific model""" + mock_data = { + "models": [ + {"name": "llama2", "digest": "abc123", "size": 3825922048} + ] + } + + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_data + mock_get.return_value = mock_response + + model = ollama_client.get_model("llama2") + assert model is not None + assert model["name"] == "llama2" + +def test_get_nonexistent_model(ollama_client): + """Test getting nonexistent model""" + mock_data = {"models": []} + + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_data + mock_get.return_value = mock_response + + model = ollama_client.get_model("nonexistent") + assert model is None + +def test_is_available(ollama_client): + """Test checking if Ollama is available""" + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + + assert ollama_client.is_available() is True + +def test_is_available_offline(ollama_client): + """Test checking if Ollama is available when offline""" + with patch("requests.get") as mock_get: + mock_get.side_effect = Exception("Connection refused") + + assert ollama_client.is_available() is False + +def test_pull_model(ollama_client): + """Test pulling a model""" + with patch("requests.post") as mock_post: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_post.return_value = mock_response + + result = ollama_client.pull_model("llama2") + assert result is True + +def test_pull_model_error(ollama_client): + """Test pull model when error occurs""" + with patch("requests.post") as mock_post: + mock_post.side_effect = Exception("Error") + + result = ollama_client.pull_model("llama2") + assert result is False + +def test_delete_model(ollama_client): + """Test deleting a model""" + with patch("requests.delete") as mock_delete: + mock_response = MagicMock() + mock_response.status_code = 204 + mock_delete.return_value = mock_response + + result = ollama_client.delete_model("llama2") + assert result is True + +def test_delete_model_error(ollama_client): + """Test delete model when error occurs""" + with patch("requests.delete") as mock_delete: + mock_delete.side_effect = Exception("Error") + + result = ollama_client.delete_model("llama2") + assert result is False