1469 lines
36 KiB
Markdown
1469 lines
36 KiB
Markdown
# Phase 1 Implementation Guide: Foundation
|
|
|
|
> **Duration:** Week 1 (5-7 days)
|
|
> **Goal:** Basic OpenAI-compatible API wrapper working with Open WebUI
|
|
> **Status:** Ready to implement
|
|
|
|
## Overview
|
|
|
|
Phase 1 establishes the foundational API layer that makes Open WebUI think it's talking to OpenAI, while actually routing requests through our orchestrator to Ollama. This is the critical foundation that all future phases build upon.
|
|
|
|
**End State:** Open WebUI can connect to the orchestrator, send messages, and receive responses (both streaming and non-streaming) with zero regressions from the current Ollama setup.
|
|
|
|
## Prerequisites
|
|
|
|
- [x] Ollama running on port 11434
|
|
- [x] Qdrant running on port 6333
|
|
- [x] Open WebUI running on port 82
|
|
- [x] Docker and Docker Compose available
|
|
- [x] Python 3.12 environment
|
|
- [ ] Text editor or IDE ready
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
/home/jpmschweitzer/Projects/portainer-core/
|
|
└── services/
|
|
└── ai-orchestrator/
|
|
├── Dockerfile
|
|
├── requirements.txt
|
|
├── .env.example
|
|
├── .dockerignore
|
|
├── README.md
|
|
└── src/
|
|
├── __init__.py
|
|
├── main.py # FastAPI entry point
|
|
├── config.py # Configuration
|
|
├── api/
|
|
│ ├── __init__.py
|
|
│ ├── routes.py # API routes
|
|
│ └── schemas.py # Pydantic models
|
|
└── models/
|
|
├── __init__.py
|
|
└── ollama_client.py # Ollama integration
|
|
```
|
|
|
|
## Implementation Steps
|
|
|
|
### Step 1: Create Project Structure (15 minutes)
|
|
|
|
**Commands:**
|
|
|
|
```bash
|
|
cd /home/jpmschweitzer/Projects/portainer-core
|
|
|
|
# Create directory structure
|
|
mkdir -p services/ai-orchestrator/src/api
|
|
mkdir -p services/ai-orchestrator/src/models
|
|
|
|
# Create __init__.py files
|
|
touch services/ai-orchestrator/src/__init__.py
|
|
touch services/ai-orchestrator/src/api/__init__.py
|
|
touch services/ai-orchestrator/src/models/__init__.py
|
|
```
|
|
|
|
**Verification:**
|
|
```bash
|
|
tree services/ai-orchestrator/src
|
|
```
|
|
|
|
Expected output:
|
|
```
|
|
services/ai-orchestrator/src
|
|
├── __init__.py
|
|
├── api
|
|
│ └── __init__.py
|
|
└── models
|
|
└── __init__.py
|
|
```
|
|
|
|
---
|
|
|
|
### Step 2: Create requirements.txt (5 minutes)
|
|
|
|
**File:** `services/ai-orchestrator/requirements.txt`
|
|
|
|
```txt
|
|
# FastAPI and server
|
|
fastapi==0.115.0
|
|
uvicorn[standard]==0.32.0
|
|
pydantic==2.10.4
|
|
pydantic-settings==2.7.0
|
|
|
|
# HTTP client
|
|
httpx==0.28.1
|
|
|
|
# Environment
|
|
python-dotenv==1.0.1
|
|
|
|
# Utilities
|
|
python-json-logger==2.0.7
|
|
```
|
|
|
|
**Save the file**, then verify:
|
|
```bash
|
|
cat services/ai-orchestrator/requirements.txt
|
|
```
|
|
|
|
---
|
|
|
|
### Step 3: Create Configuration Module (10 minutes)
|
|
|
|
**File:** `services/ai-orchestrator/src/config.py`
|
|
|
|
```python
|
|
"""
|
|
Configuration management for AI Orchestrator.
|
|
Loads settings from environment variables with sensible defaults.
|
|
"""
|
|
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
from typing import List
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Application settings loaded from environment variables."""
|
|
|
|
# Application
|
|
app_name: str = "AI Orchestrator"
|
|
app_version: str = "1.0.0"
|
|
debug: bool = False
|
|
log_level: str = "INFO"
|
|
|
|
# Server
|
|
host: str = "0.0.0.0"
|
|
port: int = 8084
|
|
|
|
# Ollama
|
|
ollama_base_url: str = "http://ollama:11434"
|
|
ollama_timeout: int = 300 # 5 minutes
|
|
|
|
# Model configuration
|
|
default_model: str = "gemma:7b"
|
|
lightweight_models: List[str] = ["gemma:2b", "gemma:7b"]
|
|
heavy_models: List[str] = ["mistral:7b", "gemma2:9b"]
|
|
code_models: List[str] = ["codestral:latest", "codegemma:latest"]
|
|
|
|
# Model aliases (OpenAI → Local)
|
|
model_aliases: dict = {
|
|
"gpt-3.5-turbo": "gemma:7b",
|
|
"gpt-4": "mistral:7b",
|
|
"gpt-4-turbo": "mixtral:8x7b",
|
|
"gpt-4-code": "codestral:latest",
|
|
}
|
|
|
|
# CORS
|
|
cors_origins: List[str] = ["*"]
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
case_sensitive=False
|
|
)
|
|
|
|
|
|
# Global settings instance
|
|
settings = Settings()
|
|
```
|
|
|
|
**Test it:**
|
|
```bash
|
|
cd services/ai-orchestrator
|
|
python3 -c "from src.config import settings; print(f'Default model: {settings.default_model}')"
|
|
```
|
|
|
|
Expected: `Default model: gemma:7b`
|
|
|
|
---
|
|
|
|
### Step 4: Create API Schemas (20 minutes)
|
|
|
|
**File:** `services/ai-orchestrator/src/api/schemas.py`
|
|
|
|
```python
|
|
"""
|
|
OpenAI-compatible API schemas.
|
|
Pydantic models for request/response validation.
|
|
"""
|
|
|
|
from pydantic import BaseModel, Field
|
|
from typing import List, Optional, Dict, Any, Literal
|
|
from enum import Enum
|
|
|
|
|
|
# ============================================================================
|
|
# Request Schemas
|
|
# ============================================================================
|
|
|
|
class MessageRole(str, Enum):
|
|
"""Valid message roles."""
|
|
SYSTEM = "system"
|
|
USER = "user"
|
|
ASSISTANT = "assistant"
|
|
|
|
|
|
class ChatMessage(BaseModel):
|
|
"""A single message in the conversation."""
|
|
role: MessageRole
|
|
content: str
|
|
name: Optional[str] = None
|
|
|
|
|
|
class ChatCompletionRequest(BaseModel):
|
|
"""OpenAI-compatible chat completion request."""
|
|
|
|
model: str = Field(..., description="Model to use")
|
|
messages: List[ChatMessage] = Field(..., min_length=1)
|
|
stream: bool = Field(default=False, description="Enable streaming")
|
|
|
|
# Optional parameters
|
|
temperature: Optional[float] = Field(default=0.7, ge=0, le=2)
|
|
top_p: Optional[float] = Field(default=1.0, ge=0, le=1)
|
|
max_tokens: Optional[int] = Field(default=None, ge=1)
|
|
frequency_penalty: Optional[float] = Field(default=0.0, ge=-2, le=2)
|
|
presence_penalty: Optional[float] = Field(default=0.0, ge=-2, le=2)
|
|
stop: Optional[List[str]] = None
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"model": "gpt-3.5-turbo",
|
|
"messages": [
|
|
{"role": "user", "content": "Hello!"}
|
|
],
|
|
"stream": False,
|
|
"temperature": 0.7
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Response Schemas
|
|
# ============================================================================
|
|
|
|
class ChatMessageResponse(BaseModel):
|
|
"""Response message."""
|
|
role: str = "assistant"
|
|
content: str
|
|
|
|
|
|
class ChatCompletionChoice(BaseModel):
|
|
"""A single completion choice."""
|
|
index: int = 0
|
|
message: ChatMessageResponse
|
|
finish_reason: str = "stop"
|
|
|
|
|
|
class UsageInfo(BaseModel):
|
|
"""Token usage information."""
|
|
prompt_tokens: int = 0
|
|
completion_tokens: int = 0
|
|
total_tokens: int = 0
|
|
|
|
|
|
class ChatCompletionResponse(BaseModel):
|
|
"""OpenAI-compatible chat completion response (non-streaming)."""
|
|
|
|
id: str
|
|
object: str = "chat.completion"
|
|
created: int
|
|
model: str
|
|
choices: List[ChatCompletionChoice]
|
|
usage: UsageInfo
|
|
|
|
|
|
# ============================================================================
|
|
# Streaming Response Schemas
|
|
# ============================================================================
|
|
|
|
class DeltaMessage(BaseModel):
|
|
"""Delta message for streaming."""
|
|
role: Optional[str] = None
|
|
content: Optional[str] = None
|
|
|
|
|
|
class ChatCompletionStreamChoice(BaseModel):
|
|
"""Streaming choice."""
|
|
index: int = 0
|
|
delta: DeltaMessage
|
|
finish_reason: Optional[str] = None
|
|
|
|
|
|
class ChatCompletionStreamResponse(BaseModel):
|
|
"""OpenAI-compatible streaming chunk."""
|
|
|
|
id: str
|
|
object: str = "chat.completion.chunk"
|
|
created: int
|
|
model: str
|
|
choices: List[ChatCompletionStreamChoice]
|
|
|
|
|
|
# ============================================================================
|
|
# Other Endpoints
|
|
# ============================================================================
|
|
|
|
class ModelInfo(BaseModel):
|
|
"""Model information."""
|
|
id: str
|
|
object: str = "model"
|
|
created: int = 0
|
|
owned_by: str = "local"
|
|
|
|
|
|
class ModelsListResponse(BaseModel):
|
|
"""List of available models."""
|
|
object: str = "list"
|
|
data: List[ModelInfo]
|
|
|
|
|
|
class HealthCheckResponse(BaseModel):
|
|
"""Health check response."""
|
|
status: str
|
|
version: str
|
|
ollama_connected: bool
|
|
```
|
|
|
|
**Test it:**
|
|
```bash
|
|
cd services/ai-orchestrator
|
|
python3 -c "from src.api.schemas import ChatCompletionRequest; print('Schemas loaded successfully')"
|
|
```
|
|
|
|
---
|
|
|
|
### Step 5: Create Ollama Client (30 minutes)
|
|
|
|
**File:** `services/ai-orchestrator/src/models/ollama_client.py`
|
|
|
|
```python
|
|
"""
|
|
Ollama client for model inference.
|
|
Handles both streaming and non-streaming requests.
|
|
"""
|
|
|
|
import httpx
|
|
import json
|
|
import logging
|
|
from typing import AsyncIterator, Dict, Any, Optional
|
|
from ..config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OllamaClient:
|
|
"""Client for interacting with Ollama API."""
|
|
|
|
def __init__(self):
|
|
self.base_url = settings.ollama_base_url
|
|
self.timeout = settings.ollama_timeout
|
|
self.client = httpx.AsyncClient(timeout=self.timeout)
|
|
|
|
async def close(self):
|
|
"""Close the HTTP client."""
|
|
await self.client.aclose()
|
|
|
|
def resolve_model(self, model_name: str) -> str:
|
|
"""
|
|
Resolve model alias to actual Ollama model.
|
|
|
|
Args:
|
|
model_name: Requested model name (e.g., "gpt-3.5-turbo")
|
|
|
|
Returns:
|
|
Actual Ollama model name (e.g., "gemma:7b")
|
|
"""
|
|
resolved = settings.model_aliases.get(model_name, model_name)
|
|
logger.info(f"Model resolution: {model_name} → {resolved}")
|
|
return resolved
|
|
|
|
async def generate_non_streaming(
|
|
self,
|
|
model: str,
|
|
prompt: str,
|
|
temperature: float = 0.7,
|
|
max_tokens: Optional[int] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Generate non-streaming response from Ollama.
|
|
|
|
Args:
|
|
model: Model name
|
|
prompt: User prompt
|
|
temperature: Sampling temperature
|
|
max_tokens: Maximum tokens to generate
|
|
|
|
Returns:
|
|
Dict with 'response' and 'tokens' keys
|
|
"""
|
|
actual_model = self.resolve_model(model)
|
|
|
|
payload = {
|
|
"model": actual_model,
|
|
"prompt": prompt,
|
|
"stream": False,
|
|
"options": {
|
|
"temperature": temperature,
|
|
}
|
|
}
|
|
|
|
if max_tokens:
|
|
payload["options"]["num_predict"] = max_tokens
|
|
|
|
logger.debug(f"Ollama request: {json.dumps(payload, indent=2)}")
|
|
|
|
try:
|
|
response = await self.client.post(
|
|
f"{self.base_url}/api/generate",
|
|
json=payload
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
|
|
return {
|
|
"response": result.get("response", ""),
|
|
"tokens": {
|
|
"prompt": result.get("prompt_eval_count", 0),
|
|
"completion": result.get("eval_count", 0),
|
|
"total": result.get("prompt_eval_count", 0) + result.get("eval_count", 0)
|
|
}
|
|
}
|
|
|
|
except httpx.HTTPError as e:
|
|
logger.error(f"Ollama request failed: {e}")
|
|
raise
|
|
|
|
async def generate_streaming(
|
|
self,
|
|
model: str,
|
|
prompt: str,
|
|
temperature: float = 0.7,
|
|
max_tokens: Optional[int] = None
|
|
) -> AsyncIterator[str]:
|
|
"""
|
|
Generate streaming response from Ollama.
|
|
|
|
Args:
|
|
model: Model name
|
|
prompt: User prompt
|
|
temperature: Sampling temperature
|
|
max_tokens: Maximum tokens to generate
|
|
|
|
Yields:
|
|
Token strings
|
|
"""
|
|
actual_model = self.resolve_model(model)
|
|
|
|
payload = {
|
|
"model": actual_model,
|
|
"prompt": prompt,
|
|
"stream": True,
|
|
"options": {
|
|
"temperature": temperature,
|
|
}
|
|
}
|
|
|
|
if max_tokens:
|
|
payload["options"]["num_predict"] = max_tokens
|
|
|
|
logger.debug(f"Ollama streaming request: {json.dumps(payload, indent=2)}")
|
|
|
|
try:
|
|
async with self.client.stream(
|
|
"POST",
|
|
f"{self.base_url}/api/generate",
|
|
json=payload
|
|
) as response:
|
|
response.raise_for_status()
|
|
|
|
async for line in response.aiter_lines():
|
|
if not line:
|
|
continue
|
|
|
|
try:
|
|
chunk = json.loads(line)
|
|
if "response" in chunk:
|
|
token = chunk["response"]
|
|
if token:
|
|
yield token
|
|
|
|
# Check if done
|
|
if chunk.get("done", False):
|
|
break
|
|
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"Failed to parse JSON: {line}")
|
|
continue
|
|
|
|
except httpx.HTTPError as e:
|
|
logger.error(f"Ollama streaming request failed: {e}")
|
|
raise
|
|
|
|
async def health_check(self) -> bool:
|
|
"""
|
|
Check if Ollama is healthy.
|
|
|
|
Returns:
|
|
True if healthy, False otherwise
|
|
"""
|
|
try:
|
|
response = await self.client.get(
|
|
f"{self.base_url}/api/tags",
|
|
timeout=5.0
|
|
)
|
|
return response.status_code == 200
|
|
except Exception as e:
|
|
logger.error(f"Ollama health check failed: {e}")
|
|
return False
|
|
|
|
|
|
# Global client instance
|
|
ollama_client = OllamaClient()
|
|
```
|
|
|
|
**Test it:**
|
|
```bash
|
|
cd services/ai-orchestrator
|
|
python3 -c "from src.models.ollama_client import OllamaClient; print('OllamaClient loaded successfully')"
|
|
```
|
|
|
|
---
|
|
|
|
### Step 6: Create API Routes (45 minutes)
|
|
|
|
**File:** `services/ai-orchestrator/src/api/routes.py`
|
|
|
|
```python
|
|
"""
|
|
API routes for OpenAI-compatible endpoints.
|
|
"""
|
|
|
|
import time
|
|
import json
|
|
import logging
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.responses import StreamingResponse, JSONResponse
|
|
from typing import AsyncIterator
|
|
|
|
from .schemas import (
|
|
ChatCompletionRequest,
|
|
ChatCompletionResponse,
|
|
ChatCompletionChoice,
|
|
ChatMessageResponse,
|
|
UsageInfo,
|
|
ChatCompletionStreamResponse,
|
|
ChatCompletionStreamChoice,
|
|
DeltaMessage,
|
|
ModelsListResponse,
|
|
ModelInfo,
|
|
HealthCheckResponse
|
|
)
|
|
from ..models.ollama_client import ollama_client
|
|
from ..config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ============================================================================
|
|
# Helper Functions
|
|
# ============================================================================
|
|
|
|
def build_prompt_from_messages(messages: list) -> str:
|
|
"""
|
|
Convert message list to a simple prompt string.
|
|
|
|
In Phase 1, we do simple concatenation.
|
|
Phase 2 will add proper memory management.
|
|
"""
|
|
prompt_parts = []
|
|
|
|
for msg in messages:
|
|
role = msg.role.value if hasattr(msg.role, 'value') else msg.role
|
|
content = msg.content
|
|
|
|
if role == "system":
|
|
prompt_parts.append(f"System: {content}")
|
|
elif role == "user":
|
|
prompt_parts.append(f"User: {content}")
|
|
elif role == "assistant":
|
|
prompt_parts.append(f"Assistant: {content}")
|
|
|
|
prompt_parts.append("Assistant:")
|
|
return "\n\n".join(prompt_parts)
|
|
|
|
|
|
async def stream_chat_completion(
|
|
request_id: str,
|
|
model: str,
|
|
prompt: str,
|
|
temperature: float,
|
|
max_tokens: int | None
|
|
) -> AsyncIterator[str]:
|
|
"""
|
|
Stream chat completion in OpenAI SSE format.
|
|
|
|
Yields:
|
|
Server-Sent Events formatted strings
|
|
"""
|
|
created = int(time.time())
|
|
|
|
# First chunk with role
|
|
first_chunk = ChatCompletionStreamResponse(
|
|
id=request_id,
|
|
created=created,
|
|
model=model,
|
|
choices=[
|
|
ChatCompletionStreamChoice(
|
|
index=0,
|
|
delta=DeltaMessage(role="assistant"),
|
|
finish_reason=None
|
|
)
|
|
]
|
|
)
|
|
yield f"data: {first_chunk.model_dump_json()}\n\n"
|
|
|
|
# Stream tokens
|
|
try:
|
|
async for token in ollama_client.generate_streaming(
|
|
model=model,
|
|
prompt=prompt,
|
|
temperature=temperature,
|
|
max_tokens=max_tokens
|
|
):
|
|
chunk = ChatCompletionStreamResponse(
|
|
id=request_id,
|
|
created=created,
|
|
model=model,
|
|
choices=[
|
|
ChatCompletionStreamChoice(
|
|
index=0,
|
|
delta=DeltaMessage(content=token),
|
|
finish_reason=None
|
|
)
|
|
]
|
|
)
|
|
yield f"data: {chunk.model_dump_json()}\n\n"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Streaming error: {e}")
|
|
# Send error in OpenAI format
|
|
error_chunk = {
|
|
"error": {
|
|
"message": str(e),
|
|
"type": "server_error"
|
|
}
|
|
}
|
|
yield f"data: {json.dumps(error_chunk)}\n\n"
|
|
return
|
|
|
|
# Final chunk
|
|
final_chunk = ChatCompletionStreamResponse(
|
|
id=request_id,
|
|
created=created,
|
|
model=model,
|
|
choices=[
|
|
ChatCompletionStreamChoice(
|
|
index=0,
|
|
delta=DeltaMessage(),
|
|
finish_reason="stop"
|
|
)
|
|
]
|
|
)
|
|
yield f"data: {final_chunk.model_dump_json()}\n\n"
|
|
yield "data: [DONE]\n\n"
|
|
|
|
|
|
# ============================================================================
|
|
# Routes
|
|
# ============================================================================
|
|
|
|
@router.post("/v1/chat/completions")
|
|
async def chat_completions(request: ChatCompletionRequest):
|
|
"""
|
|
OpenAI-compatible chat completions endpoint.
|
|
Supports both streaming and non-streaming.
|
|
"""
|
|
request_id = f"chatcmpl-{int(time.time() * 1000)}"
|
|
|
|
logger.info(
|
|
f"Chat request: id={request_id}, model={request.model}, "
|
|
f"messages={len(request.messages)}, stream={request.stream}"
|
|
)
|
|
|
|
# Build prompt from messages
|
|
prompt = build_prompt_from_messages(request.messages)
|
|
|
|
# Streaming response
|
|
if request.stream:
|
|
return StreamingResponse(
|
|
stream_chat_completion(
|
|
request_id=request_id,
|
|
model=request.model,
|
|
prompt=prompt,
|
|
temperature=request.temperature,
|
|
max_tokens=request.max_tokens
|
|
),
|
|
media_type="text/event-stream"
|
|
)
|
|
|
|
# Non-streaming response
|
|
try:
|
|
result = await ollama_client.generate_non_streaming(
|
|
model=request.model,
|
|
prompt=prompt,
|
|
temperature=request.temperature,
|
|
max_tokens=request.max_tokens
|
|
)
|
|
|
|
response = ChatCompletionResponse(
|
|
id=request_id,
|
|
created=int(time.time()),
|
|
model=request.model,
|
|
choices=[
|
|
ChatCompletionChoice(
|
|
index=0,
|
|
message=ChatMessageResponse(
|
|
role="assistant",
|
|
content=result["response"]
|
|
),
|
|
finish_reason="stop"
|
|
)
|
|
],
|
|
usage=UsageInfo(
|
|
prompt_tokens=result["tokens"]["prompt"],
|
|
completion_tokens=result["tokens"]["completion"],
|
|
total_tokens=result["tokens"]["total"]
|
|
)
|
|
)
|
|
|
|
logger.info(
|
|
f"Chat response: id={request_id}, "
|
|
f"tokens={result['tokens']['total']}"
|
|
)
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error(f"Chat completion error: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to generate completion: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/v1/models")
|
|
async def list_models():
|
|
"""List available models."""
|
|
|
|
# Map local models to OpenAI-style model IDs
|
|
models = [
|
|
ModelInfo(id=alias, owned_by="local")
|
|
for alias in settings.model_aliases.keys()
|
|
]
|
|
|
|
# Add actual local models
|
|
for model_list in [
|
|
settings.lightweight_models,
|
|
settings.heavy_models,
|
|
settings.code_models
|
|
]:
|
|
for model in model_list:
|
|
if model not in [m.id for m in models]:
|
|
models.append(ModelInfo(id=model, owned_by="local"))
|
|
|
|
return ModelsListResponse(data=models)
|
|
|
|
|
|
@router.get("/health")
|
|
async def health_check():
|
|
"""Health check endpoint."""
|
|
|
|
ollama_healthy = await ollama_client.health_check()
|
|
|
|
return HealthCheckResponse(
|
|
status="healthy" if ollama_healthy else "degraded",
|
|
version=settings.app_version,
|
|
ollama_connected=ollama_healthy
|
|
)
|
|
|
|
|
|
@router.get("/")
|
|
async def root():
|
|
"""Root endpoint."""
|
|
return {
|
|
"name": settings.app_name,
|
|
"version": settings.app_version,
|
|
"status": "running"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Step 7: Create Main Application (20 minutes)
|
|
|
|
**File:** `services/ai-orchestrator/src/main.py`
|
|
|
|
```python
|
|
"""
|
|
AI Orchestrator - Main FastAPI application.
|
|
OpenAI-compatible API for LangGraph agent orchestration.
|
|
"""
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from .config import settings
|
|
from .api.routes import router
|
|
from .models.ollama_client import ollama_client
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=getattr(logging, settings.log_level),
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Lifespan context manager for startup and shutdown."""
|
|
# Startup
|
|
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
|
logger.info(f"Ollama URL: {settings.ollama_base_url}")
|
|
logger.info(f"Default model: {settings.default_model}")
|
|
|
|
# Check Ollama connectivity
|
|
ollama_healthy = await ollama_client.health_check()
|
|
if ollama_healthy:
|
|
logger.info("✓ Ollama connection successful")
|
|
else:
|
|
logger.warning("✗ Ollama connection failed - some features may not work")
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
logger.info("Shutting down...")
|
|
await ollama_client.close()
|
|
|
|
|
|
# Create FastAPI app
|
|
app = FastAPI(
|
|
title=settings.app_name,
|
|
version=settings.app_version,
|
|
description="OpenAI-compatible API for multi-agent LLM orchestration",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
# Add CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Include routes
|
|
app.include_router(router)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run(
|
|
"src.main:app",
|
|
host=settings.host,
|
|
port=settings.port,
|
|
reload=settings.debug
|
|
)
|
|
```
|
|
|
|
**Test it:**
|
|
```bash
|
|
cd services/ai-orchestrator
|
|
python3 -c "from src.main import app; print('FastAPI app loaded successfully')"
|
|
```
|
|
|
|
---
|
|
|
|
### Step 8: Create Dockerfile (15 minutes)
|
|
|
|
**File:** `services/ai-orchestrator/Dockerfile`
|
|
|
|
```dockerfile
|
|
FROM python:3.12-slim
|
|
|
|
# Prevent Python from writing pyc files and buffering
|
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
PYTHONUNBUFFERED=1 \
|
|
PYTHONPATH=/app
|
|
|
|
WORKDIR /app
|
|
|
|
# Install system dependencies
|
|
RUN apt-get update && apt-get install -y \
|
|
curl \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Copy requirements first (better layer caching)
|
|
COPY requirements.txt .
|
|
|
|
# Install Python dependencies
|
|
RUN pip install --no-cache-dir --upgrade pip && \
|
|
pip install --no-cache-dir -r requirements.txt
|
|
|
|
# Copy application code
|
|
COPY ./src /app/src
|
|
|
|
# Create non-root user
|
|
RUN useradd -m -u 1000 appuser && \
|
|
chown -R appuser:appuser /app
|
|
|
|
USER appuser
|
|
|
|
# Health check
|
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
|
CMD curl -f http://localhost:8084/health || exit 1
|
|
|
|
# Expose port
|
|
EXPOSE 8084
|
|
|
|
# Run FastAPI with Uvicorn
|
|
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8084"]
|
|
```
|
|
|
|
---
|
|
|
|
### Step 9: Create .dockerignore (5 minutes)
|
|
|
|
**File:** `services/ai-orchestrator/.dockerignore`
|
|
|
|
```
|
|
__pycache__
|
|
*.pyc
|
|
*.pyo
|
|
*.pyd
|
|
.Python
|
|
*.so
|
|
*.egg
|
|
*.egg-info
|
|
dist
|
|
build
|
|
.env
|
|
.venv
|
|
venv
|
|
.git
|
|
.gitignore
|
|
.pytest_cache
|
|
.coverage
|
|
htmlcov
|
|
.mypy_cache
|
|
.ruff_cache
|
|
*.log
|
|
.DS_Store
|
|
README.md
|
|
docs/
|
|
tests/
|
|
```
|
|
|
|
---
|
|
|
|
### Step 10: Create Docker Compose Stack (15 minutes)
|
|
|
|
**File:** `/home/jpmschweitzer/Projects/portainer-core/stacks/ai-orchestrator.yml`
|
|
|
|
```yaml
|
|
version: '3.8'
|
|
|
|
# AI Orchestrator - Phase 1: Foundation
|
|
# OpenAI-compatible API wrapper for Ollama
|
|
# Port: 8084
|
|
|
|
services:
|
|
ai-orchestrator:
|
|
build:
|
|
context: /home/jpmschweitzer/Projects/portainer-core/services/ai-orchestrator
|
|
dockerfile: Dockerfile
|
|
container_name: ai-orchestrator
|
|
restart: unless-stopped
|
|
|
|
ports:
|
|
- "8084:8084"
|
|
|
|
environment:
|
|
# Application
|
|
- APP_NAME=AI Orchestrator
|
|
- APP_VERSION=1.0.0-phase1
|
|
- DEBUG=true
|
|
- LOG_LEVEL=INFO
|
|
|
|
# Server
|
|
- HOST=0.0.0.0
|
|
- PORT=8084
|
|
|
|
# Ollama connection
|
|
- OLLAMA_BASE_URL=http://ollama:11434
|
|
- OLLAMA_TIMEOUT=300
|
|
|
|
# Model configuration
|
|
- DEFAULT_MODEL=gemma:7b
|
|
- LIGHTWEIGHT_MODELS=gemma:2b,gemma:7b
|
|
- HEAVY_MODELS=mistral:7b,gemma2:9b,mixtral:8x7b
|
|
- CODE_MODELS=codestral:latest,codegemma:latest
|
|
|
|
# Model aliases (OpenAI → Local)
|
|
- MODEL_ALIAS_GPT35=gemma:7b
|
|
- MODEL_ALIAS_GPT4=mistral:7b
|
|
- MODEL_ALIAS_GPT4_TURBO=mixtral:8x7b
|
|
- MODEL_ALIAS_GPT4_CODE=codestral:latest
|
|
|
|
# CORS
|
|
- CORS_ORIGINS=http://192.168.86.149:82,http://open-webui:8080
|
|
|
|
networks:
|
|
- ai-dataplane
|
|
|
|
depends_on:
|
|
- ollama
|
|
|
|
labels:
|
|
- "com.centurylinklabs.watchtower.enable=false" # Disable auto-updates during development
|
|
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://localhost:8084/health"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 30s
|
|
|
|
networks:
|
|
ai-dataplane:
|
|
external: true
|
|
```
|
|
|
|
---
|
|
|
|
### Step 11: Create README (10 minutes)
|
|
|
|
**File:** `services/ai-orchestrator/README.md`
|
|
|
|
```markdown
|
|
# AI Orchestrator
|
|
|
|
OpenAI-compatible API for multi-agent LLM orchestration with Ollama.
|
|
|
|
## Phase 1: Foundation
|
|
|
|
Basic API wrapper providing OpenAI-compatible endpoints for Open WebUI.
|
|
|
|
### Features
|
|
|
|
- ✅ OpenAI-compatible `/v1/chat/completions` endpoint
|
|
- ✅ Streaming and non-streaming responses
|
|
- ✅ Model aliasing (gpt-3.5-turbo → gemma:7b, etc.)
|
|
- ✅ Health checks
|
|
- ✅ Model listing
|
|
|
|
### Quick Start
|
|
|
|
```bash
|
|
# Build and deploy
|
|
cd /home/jpmschweitzer/Projects/portainer-core
|
|
docker-compose -f stacks/ai-orchestrator.yml up -d
|
|
|
|
# Check health
|
|
curl http://localhost:8084/health
|
|
|
|
# List models
|
|
curl http://localhost:8084/v1/models
|
|
|
|
# Test chat (non-streaming)
|
|
curl http://localhost:8084/v1/chat/completions \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"model": "gpt-3.5-turbo",
|
|
"messages": [{"role": "user", "content": "Hello!"}],
|
|
"stream": false
|
|
}'
|
|
```
|
|
|
|
### Configuration
|
|
|
|
Environment variables (see docker-compose):
|
|
- `OLLAMA_BASE_URL`: Ollama API URL
|
|
- `DEFAULT_MODEL`: Default model for requests
|
|
- `LOG_LEVEL`: Logging level (DEBUG, INFO, WARNING, ERROR)
|
|
|
|
### Development
|
|
|
|
```bash
|
|
# Install dependencies
|
|
pip install -r requirements.txt
|
|
|
|
# Run locally
|
|
python -m src.main
|
|
|
|
# Or with uvicorn
|
|
uvicorn src.main:app --reload --host 0.0.0.0 --port 8084
|
|
```
|
|
|
|
### Next Steps
|
|
|
|
- [ ] Phase 2: Memory systems (3-tier architecture)
|
|
- [ ] Phase 3: Multi-agent workflows (LangGraph)
|
|
- [ ] Phase 4: Tool integration
|
|
- [ ] Phase 5: RAG and hybrid search
|
|
- [ ] Phase 6: Production hardening
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Plan
|
|
|
|
### Test 1: Local Python Test (Before Docker)
|
|
|
|
```bash
|
|
cd /home/jpmschweitzer/Projects/portainer-core/services/ai-orchestrator
|
|
|
|
# Install dependencies in a venv (optional but recommended)
|
|
python3 -m venv venv
|
|
source venv/bin/activate
|
|
pip install -r requirements.txt
|
|
|
|
# Test imports
|
|
python3 -c "from src.main import app; print('✓ App imports successfully')"
|
|
|
|
# Run locally (requires Ollama accessible at localhost:11434)
|
|
# Temporarily change OLLAMA_BASE_URL to http://localhost:11434
|
|
OLLAMA_BASE_URL=http://localhost:11434 uvicorn src.main:app --host 0.0.0.0 --port 8084
|
|
```
|
|
|
|
**Expected Output:**
|
|
```
|
|
INFO: Started server process [12345]
|
|
INFO: Waiting for application startup.
|
|
INFO: Starting AI Orchestrator v1.0.0-phase1
|
|
INFO: Ollama URL: http://localhost:11434
|
|
INFO: Default model: gemma:7b
|
|
INFO: ✓ Ollama connection successful
|
|
INFO: Application startup complete.
|
|
INFO: Uvicorn running on http://0.0.0.0:8084
|
|
```
|
|
|
|
**Test in another terminal:**
|
|
```bash
|
|
# Health check
|
|
curl http://localhost:8084/health
|
|
|
|
# Expected: {"status":"healthy","version":"1.0.0-phase1","ollama_connected":true}
|
|
|
|
# List models
|
|
curl http://localhost:8084/v1/models | jq
|
|
|
|
# Test chat
|
|
curl http://localhost:8084/v1/chat/completions \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"model": "gpt-3.5-turbo",
|
|
"messages": [{"role": "user", "content": "Say hello in one word"}],
|
|
"stream": false
|
|
}' | jq
|
|
```
|
|
|
|
---
|
|
|
|
### Test 2: Docker Build Test
|
|
|
|
```bash
|
|
cd /home/jpmschweitzer/Projects/portainer-core/services/ai-orchestrator
|
|
|
|
# Build image
|
|
docker build -t ai-orchestrator:phase1 .
|
|
|
|
# Expected: Successfully built image
|
|
|
|
# Verify image
|
|
docker images | grep ai-orchestrator
|
|
|
|
# Expected: ai-orchestrator phase1 [image-id] [size]
|
|
```
|
|
|
|
---
|
|
|
|
### Test 3: Docker Compose Deployment
|
|
|
|
```bash
|
|
cd /home/jpmschweitzer/Projects/portainer-core
|
|
|
|
# Deploy stack
|
|
docker-compose -f stacks/ai-orchestrator.yml up -d
|
|
|
|
# Check container
|
|
docker ps | grep ai-orchestrator
|
|
|
|
# Check logs
|
|
docker logs ai-orchestrator
|
|
|
|
# Expected logs:
|
|
# INFO: Starting AI Orchestrator v1.0.0-phase1
|
|
# INFO: Ollama URL: http://ollama:11434
|
|
# INFO: ✓ Ollama connection successful
|
|
```
|
|
|
|
---
|
|
|
|
### Test 4: API Functionality Tests
|
|
|
|
**Test health endpoint:**
|
|
```bash
|
|
curl http://192.168.86.149:8084/health
|
|
```
|
|
|
|
Expected:
|
|
```json
|
|
{
|
|
"status": "healthy",
|
|
"version": "1.0.0-phase1",
|
|
"ollama_connected": true
|
|
}
|
|
```
|
|
|
|
**Test models endpoint:**
|
|
```bash
|
|
curl http://192.168.86.149:8084/v1/models | jq '.data[].id'
|
|
```
|
|
|
|
Expected:
|
|
```
|
|
"gpt-3.5-turbo"
|
|
"gpt-4"
|
|
"gpt-4-turbo"
|
|
"gpt-4-code"
|
|
"gemma:2b"
|
|
"gemma:7b"
|
|
...
|
|
```
|
|
|
|
**Test non-streaming chat:**
|
|
```bash
|
|
curl http://192.168.86.149:8084/v1/chat/completions \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"model": "gpt-3.5-turbo",
|
|
"messages": [
|
|
{"role": "user", "content": "Count from 1 to 5"}
|
|
],
|
|
"stream": false,
|
|
"temperature": 0.7
|
|
}' | jq
|
|
```
|
|
|
|
Expected:
|
|
```json
|
|
{
|
|
"id": "chatcmpl-...",
|
|
"object": "chat.completion",
|
|
"created": 1699564800,
|
|
"model": "gpt-3.5-turbo",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "1\n2\n3\n4\n5"
|
|
},
|
|
"finish_reason": "stop"
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 12,
|
|
"total_tokens": 22
|
|
}
|
|
}
|
|
```
|
|
|
|
**Test streaming chat:**
|
|
```bash
|
|
curl http://192.168.86.149:8084/v1/chat/completions \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"model": "gpt-3.5-turbo",
|
|
"messages": [
|
|
{"role": "user", "content": "Say hello"}
|
|
],
|
|
"stream": true
|
|
}'
|
|
```
|
|
|
|
Expected (streaming output):
|
|
```
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
|
|
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
|
|
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}
|
|
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
|
|
|
data: [DONE]
|
|
```
|
|
|
|
---
|
|
|
|
### Test 5: Open WebUI Integration
|
|
|
|
**Update Open WebUI configuration:**
|
|
|
|
```bash
|
|
# Edit open-webui stack
|
|
nano /home/jpmschweitzer/Projects/portainer-core/stacks/open-webui.yml
|
|
|
|
# Add this environment variable:
|
|
environment:
|
|
- OPENAI_API_BASE=http://ai-orchestrator:8084/v1
|
|
- ENABLE_OPENAI_API=true
|
|
- OLLAMA_BASE_URL=http://ollama:11434 # Keep as fallback
|
|
|
|
# Restart Open WebUI
|
|
docker-compose -f stacks/open-webui.yml down
|
|
docker-compose -f stacks/open-webui.yml up -d
|
|
```
|
|
|
|
**Test in Open WebUI:**
|
|
1. Open http://192.168.86.149:82
|
|
2. Go to Settings → Connections
|
|
3. Verify you can see both endpoints
|
|
4. Select "OpenAI API"
|
|
5. Choose model "gpt-3.5-turbo" (which maps to gemma:7b)
|
|
6. Start a conversation: "Hello, testing Phase 1"
|
|
7. Verify response arrives
|
|
8. Try streaming: Should see tokens appear one by one
|
|
|
|
---
|
|
|
|
## Success Criteria Checklist
|
|
|
|
### Code Quality
|
|
- [ ] All Python files have proper imports
|
|
- [ ] No syntax errors
|
|
- [ ] Type hints where appropriate
|
|
- [ ] Docstrings for functions
|
|
- [ ] Logging configured
|
|
|
|
### Functionality
|
|
- [ ] Health endpoint returns 200
|
|
- [ ] Models endpoint lists available models
|
|
- [ ] Non-streaming chat works
|
|
- [ ] Streaming chat works
|
|
- [ ] Model aliases resolve correctly
|
|
- [ ] Error handling works (try invalid model name)
|
|
|
|
### Docker
|
|
- [ ] Image builds successfully
|
|
- [ ] Container starts without errors
|
|
- [ ] Container passes health check
|
|
- [ ] Can connect to Ollama from container
|
|
- [ ] Logs are readable
|
|
|
|
### Integration
|
|
- [ ] Open WebUI can connect
|
|
- [ ] Can send messages through orchestrator
|
|
- [ ] Responses appear in Open WebUI
|
|
- [ ] Streaming works in UI
|
|
- [ ] No regressions from direct Ollama connection
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: "Ollama connection failed"
|
|
|
|
**Symptoms:** Health check shows `ollama_connected: false`
|
|
|
|
**Solutions:**
|
|
1. Check Ollama is running: `docker ps | grep ollama`
|
|
2. Check network connectivity: `docker exec ai-orchestrator curl http://ollama:11434/api/tags`
|
|
3. Verify both containers on ai-dataplane network
|
|
4. Check Ollama logs: `docker logs ollama`
|
|
|
|
### Issue: "Model not found"
|
|
|
|
**Symptoms:** Error when trying to use a model
|
|
|
|
**Solutions:**
|
|
1. List available models in Ollama: `docker exec ollama ollama list`
|
|
2. Pull missing model: `docker exec ollama ollama pull gemma:7b`
|
|
3. Check model aliases in config.py
|
|
4. Verify model name spelling
|
|
|
|
### Issue: "Streaming not working"
|
|
|
|
**Symptoms:** No streaming output, or all text arrives at once
|
|
|
|
**Solutions:**
|
|
1. Check Content-Type header: Should be `text/event-stream`
|
|
2. Verify stream=true in request
|
|
3. Check browser/client supports SSE
|
|
4. Test with curl first (easier to debug)
|
|
|
|
### Issue: "Open WebUI can't connect"
|
|
|
|
**Symptoms:** Open WebUI shows connection error
|
|
|
|
**Solutions:**
|
|
1. Verify orchestrator is on ai-dataplane network
|
|
2. Check Open WebUI environment variable: `OPENAI_API_BASE=http://ai-orchestrator:8084/v1`
|
|
3. Test from Open WebUI container: `docker exec open-webui curl http://ai-orchestrator:8084/health`
|
|
4. Check Open WebUI logs: `docker logs open-webui`
|
|
|
|
---
|
|
|
|
## Next Steps After Phase 1
|
|
|
|
Once Phase 1 is complete and all tests pass:
|
|
|
|
1. **Document any issues encountered** - Add to troubleshooting
|
|
2. **Commit to git** (if using version control)
|
|
3. **Update STATUS.md** - Mark Phase 1 complete
|
|
4. **Review with stakeholders** - Demo the working system
|
|
5. **Plan Phase 2 kickoff** - Memory systems implementation
|
|
|
|
---
|
|
|
|
## Time Estimates
|
|
|
|
| Step | Task | Time |
|
|
|------|------|------|
|
|
| 1 | Create structure | 15 min |
|
|
| 2 | requirements.txt | 5 min |
|
|
| 3 | config.py | 10 min |
|
|
| 4 | schemas.py | 20 min |
|
|
| 5 | ollama_client.py | 30 min |
|
|
| 6 | routes.py | 45 min |
|
|
| 7 | main.py | 20 min |
|
|
| 8 | Dockerfile | 15 min |
|
|
| 9 | .dockerignore | 5 min |
|
|
| 10 | docker-compose | 15 min |
|
|
| 11 | README.md | 10 min |
|
|
| **Coding Total** | | **~3 hours** |
|
|
| | | |
|
|
| Test 1 | Local test | 30 min |
|
|
| Test 2 | Docker build | 15 min |
|
|
| Test 3 | Deploy | 15 min |
|
|
| Test 4 | API tests | 30 min |
|
|
| Test 5 | Open WebUI | 30 min |
|
|
| **Testing Total** | | **~2 hours** |
|
|
| | | |
|
|
| **Grand Total** | | **~5 hours** |
|
|
|
|
Add buffer time for debugging: **1-2 hours**
|
|
|
|
**Total realistic time: 6-7 hours (one work day)**
|
|
|
|
---
|
|
|
|
Ready to start implementation? I can help you with any step!
|