From 4b782ffdc8e84891aec94914ce45c1a9554ca94d Mon Sep 17 00:00:00 2001 From: Luca Sacchi Ricciardi Date: Fri, 24 Apr 2026 19:11:58 +0200 Subject: [PATCH] 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 --- .dockerignore | 44 ++++ .editorconfig | 35 ++++ .env.local | 15 ++ .gitignore | 145 +++++++++++++ CONTRIBUTING.md | 123 +++++++++++ Dockerfile | 52 +++++ LICENSE | 21 ++ Makefile | 54 +++++ README.md | 381 +++++++++++++++++++++++++++++++++++ app/__init__.py | 5 + app/api/__init__.py | 3 + app/api/health.py | 70 +++++++ app/api/models.py | 232 +++++++++++++++++++++ app/config.py | 34 ++++ app/services/__init__.py | 3 + app/services/ollama.py | 116 +++++++++++ app/web/__init__.py | 3 + app/web/templates/index.html | 224 ++++++++++++++++++++ docker-compose.yml | 69 +++++++ env.example | 52 +++++ main.py | 82 ++++++++ package.json | 13 ++ requirements-dev.txt | 26 +++ requirements.txt | 34 ++++ tests/__init__.py | 3 + tests/conftest.py | 32 +++ tests/test_api.py | 94 +++++++++ tests/test_ollama.py | 122 +++++++++++ 28 files changed, 2087 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.local create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/health.py create mode 100644 app/api/models.py create mode 100644 app/config.py create mode 100644 app/services/__init__.py create mode 100644 app/services/ollama.py create mode 100644 app/web/__init__.py create mode 100644 app/web/templates/index.html create mode 100644 docker-compose.yml create mode 100644 env.example create mode 100644 main.py create mode 100644 package.json create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_ollama.py 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