Retires the Moonshot/Kimi subscription in favour of the already-paid ChatGPT plan. Both CLI wrappers now run `codex exec`; the kimi-agent container is gone. adolf-llm + hindsight-llm: - runKimi -> runCodex (`codex exec --json --skip-git-repo-check`), resume via `codex exec resume <thread_id>`. - MCP moves from a per-session .mcp.json (a workaround for Kimi having no --mcp-config-file flag) to a $CODEX_HOME/config.toml generated once at startup from shared-mcp.json. Field translation is load-bearing: bearerTokenEnvVar -> bearer_token_env_var, enabledTools -> enabled_tools. - approval_policy="never" + sandbox_mode required, or unattended turns block on an approval prompt nobody can answer. kimi-agent removed. It was the ONLY large-tier deployment behind LiteLLM, so deleting it outright would have silently degraded every large-tier request to the local 4B model via the existing fallbacks. tier-large, the auto_router complex-reasoning route and their fallbacks now point at the codex-backed adolf-llm wrapper (model_name: codex-agent). Three environment blockers fixed along the way: - OpenAI geo-blocks this host (403 unsupported_country_region_territory). Both containers now egress via the host xray proxy, with NO_PROXY keeping MCP and *.alogins.net traffic off the tunnel. - node:22-slim ships no system CA store; the Rust codex binary validates TLS against it, so every HTTPS call failed with a generic transport error while Node's own fetch worked. ca-certificates added to both images. - `codex exec resume` rejects -C/--cd (plain `codex exec` accepts it), which broke follow-up turns while first turns succeeded. Known regression: Kimi's managed-usage API has no Codex equivalent, so the /usage route returns 501 and there is no quota probe for the codex model. The two quota plugins degrade quietly to no output. Also: stop tracking cognee.env (live LLM + JWT secrets) and gitignore it. The secrets remain in earlier history and should be rotated. Verified live: plain turn, SSE streaming, session resume, MCP tool call, bearer-token MCP call, and completions through both LiteLLM routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Y5QPagv4iun1ghpwM96Ff
154 lines
5.3 KiB
Python
154 lines
5.3 KiB
Python
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)
|