homelab-codex-ws/services/llm-gateway/app/main.py
oskar 70ff08b4a0 feat(llm-gateway): wciagniecie shadow-serwisu z PIHA do GitOps
Kod zyl tylko na dysku PIHA w /opt/llm-gateway (bez gita). Przeniesiony do
services/llm-gateway/ pod pelny wzorzec homelaba:

- app/main.py: kod 1:1 z PIHA + OLLAMA_URL/CHAT_MODEL/CODE_MODEL
  nadpisywalne przez env (defaulty bez zmian)
- docker-compose.yml: bind TYLKO do Tailscale IP (${TAILSCALE_BIND_IP},
  wzorzec fleet-prometheus), nie 0.0.0.0 jak w starym compose
- service.yaml, env.example (bez sekretow), healthcheck.sh, README, testy
- hosts/piha/runtime/llm-gateway: mem_limit 256m (PIHA jest RAM-bound)
- rejestracja w hosts/piha/services.yaml i inventory/topology.yaml

Zepsuty /opt/llm-gateway/docker-compose.yml (zduplikowany klucz ports)
celowo NIE przeniesiony.

DoD: pytest 4 passed; docker build + smoke run OK (GET / -> gateway ok).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:41:44 +02:00

100 lines
2.7 KiB
Python

import os
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
class PromptRequest(BaseModel):
prompt: str
stream: bool = False
app = FastAPI()
# Ollama lives on SOLARIA (GPU node); resolved via Tailscale MagicDNS on the
# host, which Docker's embedded DNS forwards to. Overridable per node via env.
OLLAMA_URL = os.environ.get('OLLAMA_URL', 'http://solaria:11434/api/generate')
CHAT_MODEL = os.environ.get('CHAT_MODEL', 'deepcoder:14b')
CODE_MODEL = os.environ.get('CODE_MODEL', 'deepseek-coder:latest')
NATURAL_LANGUAGE_STARTERS = {
'write',
'create',
'generate',
'make',
'say',
'show',
'give',
'explain',
'tell',
'build',
}
@app.get('/')
async def root() -> dict[str, str]:
return {'status': 'gateway ok'}
async def generate(route: str, model: str, request: PromptRequest) -> dict[str, object]:
payload = {
'model': model,
'prompt': request.prompt,
'stream': False,
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
ollama_response = await client.post(OLLAMA_URL, json=payload)
ollama_response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=f'Ollama error: {exc.response.text}') from exc
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f'Ollama unreachable: {exc}') from exc
raw = ollama_response.json()
return {
'route': route,
'model': model,
'response': raw.get('response', ''),
'raw': raw,
}
def looks_like_shell_command(prompt: str) -> bool:
stripped = prompt.strip()
if not stripped or '\n' in stripped:
return False
first_token = stripped.split()[0].lower()
if first_token in NATURAL_LANGUAGE_STARTERS:
return False
return True
@app.post('/api/chat')
async def chat(request: PromptRequest) -> dict[str, object]:
return await generate('chat', CHAT_MODEL, request)
@app.post('/api/code')
async def code(request: PromptRequest) -> dict[str, object]:
prompt = request.prompt
code_prompt = f"""
You are a coding and command assistant.
Return ONLY the final answer.
Do NOT explain.
Do NOT use markdown.
Do NOT mention that you are an AI.
Do NOT add prose.
If the task is a shell command request, return only the shell command.
If the task is a programming request, return only the code.
TASK:
{prompt}
""".strip()
code_request = PromptRequest(prompt=code_prompt, stream=request.stream)
result = await generate('code', CODE_MODEL, code_request)
if looks_like_shell_command(prompt):
result['response'] = prompt.strip()
return result