openai: add AI stack (litellm + langfuse + pipecat + silero-tts) and oO aliases

- LiteLLM proxy with langfuse callbacks, postgres backends, and OpenRouter fallbacks.
- Langfuse observability UI.
- Pipecat voice pipeline (LiveKit + STT + TTS + LLM) and Silero TTS build contexts.
- Ollama tuned for GPU (OLLAMA_NUM_GPU=999, mem_limit=4g, max 2 loaded models).
- open-webui wired to litellm + faster-whisper + silero for voice.
- litellm-config.yaml publishes oO's model aliases (tip-generator, embedder, judge)
  pointing at the host ollama on :11434 so ml/serving can call them via LiteLLM.

.env skipped (secrets).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Alvis
2026-04-20 14:28:24 +00:00
parent 52190b63b8
commit 85033136d8
8 changed files with 1020 additions and 7 deletions

153
openai/silero-tts/server.py Normal file
View File

@@ -0,0 +1,153 @@
import io
import re
import logging
import numpy as np
import torch
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
import scipy.io.wavfile as wavfile
from pydub import AudioSegment
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Silero TTS")
# ── Config ────────────────────────────────────────────────────────────────────
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
SAMPLE_RATE = 24000
MAX_CHUNK = 800 # chars per Silero call
# Model identifiers (passed as `speaker` to torch.hub.load — selects model file)
MODEL_ID = {"ru": "v3_1_ru", "en": "v3_en"}
# Silero speakers (passed to apply_tts)
RU_SPEAKERS = ["aidar", "baya", "kseniya", "xenia", "eugene"]
EN_SPEAKERS = [f"en_{i}" for i in range(10)]
# OpenAI voice → Silero speaker
VOICE_MAP = {
"ru": {"alloy": "eugene", "echo": "aidar", "fable": "baya",
"onyx": "eugene", "nova": "kseniya", "shimmer": "xenia"},
"en": {"alloy": "en_3", "echo": "en_1", "fable": "en_2",
"onyx": "en_3", "nova": "en_4", "shimmer": "en_5"},
}
# ── Model cache ───────────────────────────────────────────────────────────────
_models: dict[str, object] = {}
def _get_model(language: str):
if language not in _models:
logger.info(f"Loading Silero model {MODEL_ID[language]} lang={language} device={DEVICE}")
model, _ = torch.hub.load(
repo_or_dir="snakers4/silero-models",
model="silero_tts",
language=language,
speaker=MODEL_ID[language],
trust_repo=True,
)
model.to(DEVICE)
_models[language] = model
logger.info(f"Model ready: lang={language}")
return _models[language]
@app.on_event("startup")
async def preload():
"""Preload both language models to avoid cold-start on first request."""
for lang in ("ru", "en"):
try:
_get_model(lang)
except Exception as e:
logger.warning(f"Preload failed for lang={lang}: {e}")
# ── Helpers ───────────────────────────────────────────────────────────────────
def _is_russian(text: str) -> bool:
return bool(re.search(r"[а-яёА-ЯЁ]", text))
def _split_sentences(text: str) -> list[str]:
"""Split on sentence boundaries, keeping chunks under MAX_CHUNK chars."""
if len(text) <= MAX_CHUNK:
return [text]
parts = re.split(r"(?<=[.!?;])\s+", text.strip())
chunks, cur = [], ""
for part in parts:
if len(cur) + len(part) + 1 <= MAX_CHUNK:
cur = f"{cur} {part}" if cur else part
else:
if cur:
chunks.append(cur)
# If single part is too long, split mid-word as last resort
cur = part[:MAX_CHUNK] if len(part) > MAX_CHUNK else part
if cur:
chunks.append(cur)
return chunks or [text[:MAX_CHUNK]]
def _to_bytes(audio: torch.Tensor, fmt: str) -> bytes:
pcm = (audio.cpu().numpy() * 32767).astype(np.int16)
if fmt == "pcm":
return pcm.tobytes()
buf = io.BytesIO()
wavfile.write(buf, SAMPLE_RATE, pcm)
if fmt == "wav":
return buf.getvalue()
seg = AudioSegment.from_wav(io.BytesIO(buf.getvalue()))
out = io.BytesIO()
seg.export(out, format="mp3")
return out.getvalue()
# ── API ───────────────────────────────────────────────────────────────────────
class SpeechRequest(BaseModel):
model: str = "silero"
input: str
voice: str = "alloy"
response_format: str = "mp3"
speed: float = 1.0
@app.get("/health")
async def health():
return {"status": "ok", "device": DEVICE}
@app.get("/v1/models")
async def list_models():
return {
"object": "list",
"data": [{"id": "silero", "object": "model", "owned_by": "silero"}],
}
@app.post("/v1/audio/speech")
async def speech(req: SpeechRequest):
text = req.input.strip()
if not text:
raise HTTPException(status_code=400, detail="input is empty")
language = "ru" if _is_russian(text) else "en"
vm = VOICE_MAP[language]
speaker = vm.get(req.voice, vm["alloy"])
try:
model = _get_model(language)
chunks = _split_sentences(text)
parts = [
model.apply_tts(text=chunk, speaker=speaker, sample_rate=SAMPLE_RATE)
for chunk in chunks
]
audio = parts[0] if len(parts) == 1 else torch.cat(parts)
except Exception as e:
logger.error(f"TTS error: {e}")
raise HTTPException(status_code=500, detail=str(e))
fmt = req.response_format.lower()
audio_bytes = _to_bytes(audio, fmt)
media_types = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg"}
media_type = media_types.get(fmt, "audio/mpeg")
return Response(content=audio_bytes, media_type=media_type)