xtts: keep the protocol clean and accept the licence; surya on a pod serves docTR

XTTS reached its own loader on a pod and died on 'unparseable worker reply'.
coqui prints an interactive CPML licence prompt to stdout and then blocks on
input() — on a pipe, that corrupts the JSON protocol and then waits forever.

Two fixes. The service now keeps the real stdout for the protocol and sends
everything else the process prints to stderr, so no library chatter can reach
the caller's parser again. And the CPML acceptance travels as COQUI_TOS_AGREED,
locally and to a pod: configuring an XTTS model at all IS the decision, exactly
as surya's licence is handled, and the pod inherits it. Verified in the image:
the load returns clean JSON with the full speaker list.

Surya: 0.22 is a VLM (surya-ocr-2), not a self-contained OCR library. It needs
a server — vLLM, which it spawns IN DOCKER, or llama-server on a GGUF — and the
pod image has neither; locally it rides coderai's own vLLM engine. A pod asked
for surya now serves the request with docTR, which is in the image and has
passed, and logs why — instead of loading surya, reaching inference, and dying
on 'docker binary not found'. Surya on a pod belongs with the engine images,
where a llama-server and the GGUF are the same work as colibri and ds4.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 64ac2d05
...@@ -762,9 +762,28 @@ def _plan(engine, image, args, mcfg, api_key, entry, served, ...@@ -762,9 +762,28 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
("CODERAI_XTTS_VENV", "TTS")): ("CODERAI_XTTS_VENV", "TTS")):
env.setdefault(_var, f"/opt/coderai/venvs/{_name}") env.setdefault(_var, f"/opt/coderai/venvs/{_name}")
if cap in ("tts", "voice"):
# coqui XTTS is under the CPML licence and asks for acceptance with an
# interactive prompt on stdout — which on a pod both corrupts the worker
# protocol and blocks forever on input(). The decision is made HERE by
# configuring the model at all, exactly as surya's licence is; the pod
# inherits it and coqui skips the prompt.
env.setdefault("COQUI_TOS_AGREED", "1")
if cap == "ocr": if cap == "ocr":
env["CODERAI_OCR_ENABLED"] = "1" env["CODERAI_OCR_ENABLED"] = "1"
wanted = str((entry or {}).get("path") or "").strip().lower() wanted = str((entry or {}).get("path") or "").strip().lower()
if wanted == "surya":
# Surya 0.22 is a VLM ("surya-ocr-2"), not a self-contained OCR
# library: it needs a server — vLLM, which it spawns IN DOCKER (a
# pod cannot), or llama-server on a GGUF, which the image does not
# ship. Locally it rides coderai's own vLLM engine; a pod has none.
# So a pod asked for surya serves the request with docTR, which is
# in the image and has passed — rather than loading surya, reaching
# inference, and dying on "docker binary not found".
print("[runpod] surya on a pod needs a VLM server the image does not "
"carry — this OCR request will be served by docTR", flush=True)
wanted = "doctr"
if wanted not in ("paddle", "doctr", "surya"): if wanted not in ("paddle", "doctr", "surya"):
# The pod images ship docTR, the only engine that runs in-process. # The pod images ship docTR, the only engine that runs in-process.
wanted = "doctr" wanted = "doctr"
...@@ -777,12 +796,6 @@ def _plan(engine, image, args, mcfg, api_key, entry, served, ...@@ -777,12 +796,6 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
# CUDA runtime. Point the engine at that interpreter rather than # CUDA runtime. Point the engine at that interpreter rather than
# letting it try to build a venv on a rented machine. # letting it try to build a venv on a rented machine.
env["CODERAI_OCR_PADDLE_VENV"] = "/opt/coderai/venvs/paddleocr" env["CODERAI_OCR_PADDLE_VENV"] = "/opt/coderai/venvs/paddleocr"
if wanted == "surya":
# Surya is installed in the pod's main venv — its old pillow<11 cap
# no longer applies — so point its subprocess engine at that
# interpreter. Building a venv on a pod would cost minutes of rental
# to install what is already there.
env["CODERAI_OCR_SURYA_VENV"] = "/usr/local"
if wanted == "surya" or _surya_accepted(): if wanted == "surya" or _surya_accepted():
# Surya is GPL and gated on an explicit acceptance. The pod inherits # Surya is GPL and gated on an explicit acceptance. The pod inherits
# the decision made HERE — it cannot make it for itself, and asking # the decision made HERE — it cannot make it for itself, and asking
......
...@@ -74,7 +74,10 @@ class XttsSubprocess: ...@@ -74,7 +74,10 @@ class XttsSubprocess:
self._proc = subprocess.Popen( self._proc = subprocess.Popen(
[str(venv_python()), str(_SERVICE)], [str(venv_python()), str(_SERVICE)],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1,
env={**os.environ, "PYTHONUNBUFFERED": "1"}) env={**os.environ, "PYTHONUNBUFFERED": "1",
# CPML acceptance: configuring an XTTS model IS the decision,
# and the alternative is an interactive prompt on a pipe.
"COQUI_TOS_AGREED": os.environ.get("COQUI_TOS_AGREED", "1")})
reply = self._call({"op": "load", "model": model_name}) reply = self._call({"op": "load", "model": model_name})
if not reply.get("ok"): if not reply.get("ok"):
self.close() self.close()
......
...@@ -597,16 +597,16 @@ def test_an_ocr_pod_arrives_with_ocr_switched_on(): ...@@ -597,16 +597,16 @@ def test_an_ocr_pod_arrives_with_ocr_switched_on():
from codai.api.runpod_worker import pod_plan, parse_model_runpod from codai.api.runpod_worker import pod_plan, parse_model_runpod
mcfg = parse_model_runpod({"engine": "coderai", "image": "x:1"}) mcfg = parse_model_runpod({"engine": "coderai", "image": "x:1"})
# Surya 0.22 is a VLM that needs a server the pod image does not carry —
# vLLM (which it spawns in Docker) or llama-server on a GGUF. A pod asked
# for surya serves the request with docTR, and says so, rather than loading
# surya, reaching inference and dying on "docker binary not found".
env = pod_plan(mcfg, "ocr", entry={"path": "surya", env = pod_plan(mcfg, "ocr", entry={"path": "surya",
"model_type": "ocr_models"})["env"] "model_type": "ocr_models"})["env"]
assert env["CODERAI_OCR_ENABLED"] == "1" assert env["CODERAI_OCR_ENABLED"] == "1"
assert env["CODERAI_OCR_DEFAULT_ENGINE"] == "surya" assert env["CODERAI_OCR_DEFAULT_ENGINE"] == "doctr"
# Asking for surya by name IS the licence decision; the pod cannot make it. assert env["CODERAI_OCR_DOCTR_ENABLED"] == "1"
assert env["CODERAI_OCR_SURYA_ACCEPT_LICENSE"] == "1" assert "CODERAI_OCR_SURYA_ENABLED" not in env
# …and the engine's OWN gate, which is separate from the subsystem's. A pod
# with only the subsystem enabled answered "OCR engine 'surya' is not
# enabled" — a second refusal from a second flag, a round later.
assert env["CODERAI_OCR_SURYA_ENABLED"] == "1"
# A non-gated engine does not carry the licence flag unless we accepted it. # A non-gated engine does not carry the licence flag unless we accepted it.
env = pod_plan(mcfg, "ocr", entry={"path": "paddle", env = pod_plan(mcfg, "ocr", entry={"path": "paddle",
...@@ -673,11 +673,12 @@ def test_a_pod_is_told_where_its_baked_venvs_are(): ...@@ -673,11 +673,12 @@ def test_a_pod_is_told_where_its_baked_venvs_are():
assert env["CODERAI_PYANNOTE_VENV"] == "/opt/coderai/venvs/pyannote" assert env["CODERAI_PYANNOTE_VENV"] == "/opt/coderai/venvs/pyannote"
assert env["CODERAI_NEMO_VENV"] == "/opt/coderai/venvs/nemo" assert env["CODERAI_NEMO_VENV"] == "/opt/coderai/venvs/nemo"
# Surya is the opposite case: it fits the main venv, so it is pointed at the # A pod asked for coqui XTTS inherits the CPML acceptance: the alternative
# pod's own interpreter rather than a venv that would only duplicate it. # is an interactive prompt on a pipe, which corrupts the worker protocol
env = pod_plan(mcfg, "ocr", entry={"path": "surya", # and then blocks forever on input().
"model_type": "ocr_models"})["env"] env = pod_plan(mcfg, "tts", entry={"path": "coqui/XTTS-v2",
assert env["CODERAI_OCR_SURYA_VENV"] == "/usr/local" "model_type": "tts_models"})["env"]
assert env["COQUI_TOS_AGREED"] == "1"
def test_a_150gb_model_warns_before_anyone_rents_a_card(): def test_a_150gb_model_warns_before_anyone_rents_a_card():
......
...@@ -93,6 +93,14 @@ def _tts(req: dict) -> dict: ...@@ -93,6 +93,14 @@ def _tts(req: dict) -> dict:
def main() -> int: def main() -> int:
# stdout is the PROTOCOL: one JSON object per line. coqui prints a licence
# prompt and progress to stdout during load, which the caller then tried to
# parse — "unparseable worker reply". So the real stdout is kept for the
# protocol and everything else the process prints goes to stderr, where the
# caller's log tail can still see it.
import os
proto = os.fdopen(os.dup(sys.stdout.fileno()), "w", buffering=1)
sys.stdout = sys.stderr
for line in sys.stdin: for line in sys.stdin:
line = line.strip() line = line.strip()
if not line: if not line:
...@@ -111,8 +119,8 @@ def main() -> int: ...@@ -111,8 +119,8 @@ def main() -> int:
except Exception as exc: except Exception as exc:
# One bad request must not take the loaded model down with it. # One bad request must not take the loaded model down with it.
out = {"ok": False, "error": f"{type(exc).__name__}: {exc}"} out = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
sys.stdout.write(json.dumps(out) + "\n") proto.write(json.dumps(out) + "\n")
sys.stdout.flush() proto.flush()
return 0 return 0
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment