images: make the model matrix actually servable, splitting only where measured

The pod images were built to satisfy imports, not to serve models. The audit
found coqui/XTTS-v2, every whisper.cpp model, diarization, speaker ID, SALAD
place recognition and Surya all unservable on a pod, and the OCR image shipping
no OCR engine at all.

Three of the eight 'isolated' stacks are not isolated any more. Surya, speechbrain
and pytorch_lightning all import cleanly beside torch 2.11 — their venvs were
decided against an older torch and never re-checked, exactly like audiocraft's
torch==2.1.0. They go straight into the ocr, stt and embeddings profiles: place
recognition, speaker ID and Surya now work for a few hundred MB instead of three
new images. Surya's subprocess engine is pointed at the pod's own interpreter,
since building a venv to hold what is already installed would only cost rental
time.

Five stacks genuinely cannot share, each verified rather than assumed:
pyannote (torchaudio.AudioMetaData is gone in 2.11), coqui-tts (a transformers
4.x symbol), CrisperWhisper (transformers 4.46 AND torch 2.5), NeMo (large, own
torch) and paddle (its own CUDA runtime). Each gets an image carrying a baked
venv, rather than 6 GB added to an image everyone pulls.

The `speaker` capability no longer points at the STT image: that was the assumption that it was
'the STT deps plus a bit', and it shipped nothing for diarization or voiceprints
at all.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 38482fc9
...@@ -180,6 +180,15 @@ CAPABILITY_IMAGE_TAG = os.environ.get("CODERAI_CAPABILITY_IMAGE_TAG", "latest") ...@@ -180,6 +180,15 @@ CAPABILITY_IMAGE_TAG = os.environ.get("CODERAI_CAPABILITY_IMAGE_TAG", "latest")
PUBLISHED_CAPABILITY_IMAGES = ( PUBLISHED_CAPABILITY_IMAGES = (
"images", "video", "embeddings", "ocr", "tts", "stt", "voice", "audio", "images", "video", "embeddings", "ocr", "tts", "stt", "voice", "audio",
"faceswap", "faceswap",
# Stacks that cannot share the main venv get an image of their own, rather
# than 6 GB added to one everybody pulls. Each carries a baked venv — see
# packaging/runpod/profiles/<name>.venv-*.txt — and each exists because a
# real conflict was measured, not assumed:
"speaker", # pyannote: torchaudio.AudioMetaData removed in 2.11
"tts-xtts", # coqui-tts: imports a transformers 4.x symbol
"stt-nemo", # NeMo Canary: large, and its own torch
"stt-crisper", # CrisperWhisper: transformers 4.46 AND torch 2.5
"ocr-paddle", # paddlepaddle-gpu: brings its own CUDA runtime
# Text: a coderai pod that serves LLMs. Unlike a vLLM/llama.cpp pod it can be # Text: a coderai pod that serves LLMs. Unlike a vLLM/llama.cpp pod it can be
# SENT a local LoRA adapter, which is the only way a local text adapter runs # SENT a local LoRA adapter, which is the only way a local text adapter runs
# on RunPod. Build and push it with packaging/runpod before using it. # on RunPod. Build and push it with packaging/runpod before using it.
...@@ -189,7 +198,11 @@ PUBLISHED_CAPABILITY_IMAGES = ( ...@@ -189,7 +198,11 @@ PUBLISHED_CAPABILITY_IMAGES = (
#: Capabilities served by a published image other than their own name. #: Capabilities served by a published image other than their own name.
_CAPABILITY_IMAGE_ALIASES = { _CAPABILITY_IMAGE_ALIASES = {
"rerank": "embeddings", # same sentence-transformers stack "rerank": "embeddings", # same sentence-transformers stack
"speaker": "stt", # diarization/voiceprints ride the STT deps # `speaker` used to ride the STT image on the assumption it was "the STT
# deps plus a bit". It was not: that image shipped nothing for diarization
# or voiceprints at all, so a speaker pod booted with no way to embed a
# voice. pyannote needs a torch the main venv cannot have, so it has its own
# image now.
"stems": "audio", "stems": "audio",
"audio_clean": "audio", "audio_clean": "audio",
"audio_gen": "audio", "audio_gen": "audio",
...@@ -606,6 +619,15 @@ def _plan(engine, image, args, mcfg, api_key, entry, served, ...@@ -606,6 +619,15 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
choice = str((entry or {}).get("audio_backend") or "").strip().lower() choice = str((entry or {}).get("audio_backend") or "").strip().lower()
if choice in ("audiocraft", "transformers"): if choice in ("audiocraft", "transformers"):
env["CODERAI_AUDIO_BACKEND"] = choice env["CODERAI_AUDIO_BACKEND"] = choice
# Subsystems whose image bakes a venv: tell the worker where it is, or it
# tries to build one on a machine rented by the second. Harmless on an image
# that has no such venv — the worker falls back to its own search.
for _var, _name in (("CODERAI_PYANNOTE_VENV", "pyannote"),
("CODERAI_NEMO_VENV", "nemo"),
("CODERAI_CRISPERWHISPER_VENV", "transformers"),
("CODERAI_XTTS_VENV", "TTS")):
env.setdefault(_var, f"/opt/coderai/venvs/{_name}")
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()
...@@ -616,6 +638,17 @@ def _plan(engine, image, args, mcfg, api_key, entry, served, ...@@ -616,6 +638,17 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
# The subsystem flag is not enough: each engine has a gate of its own, # The subsystem flag is not enough: each engine has a gate of its own,
# and doctr and surya both default to off. # and doctr and surya both default to off.
env[f"CODERAI_OCR_{wanted.upper()}_ENABLED"] = "1" env[f"CODERAI_OCR_{wanted.upper()}_ENABLED"] = "1"
if wanted == "paddle":
# The paddle image bakes it, because paddlepaddle brings its own
# CUDA runtime. Point the engine at that interpreter rather than
# letting it try to build a venv on a rented machine.
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
......
...@@ -1346,6 +1346,13 @@ class ConfigManager: ...@@ -1346,6 +1346,13 @@ class ConfigManager:
for name in ("paddle", "doctr", "surya"): for name in ("paddle", "doctr", "surya"):
if _flag(f"CODERAI_OCR_{name.upper()}_ENABLED"): if _flag(f"CODERAI_OCR_{name.upper()}_ENABLED"):
setattr(self.config.ocr, f"{name}_enabled", True) setattr(self.config.ocr, f"{name}_enabled", True)
# Where that engine's interpreter lives. A pod image that ships
# the engine in its OWN venv points this at that interpreter, so
# the subprocess engine runs it instead of trying to build a venv
# on a machine rented by the second.
venv = (_os.environ.get(f"CODERAI_OCR_{name.upper()}_VENV") or "").strip()
if venv:
setattr(self.config.ocr, f"{name}_venv", venv)
if _flag("CODERAI_OCR_SURYA_ACCEPT_LICENSE"): if _flag("CODERAI_OCR_SURYA_ACCEPT_LICENSE"):
# Surya is GPL and gated on an explicit acceptance. The pod # Surya is GPL and gated on an explicit acceptance. The pod
# inherits the decision made here; it cannot make it itself. # inherits the decision made here; it cannot make it itself.
......
...@@ -4,3 +4,10 @@ sentence-transformers>=2.7.0 ...@@ -4,3 +4,10 @@ sentence-transformers>=2.7.0
geoclip>=1.2.0 geoclip>=1.2.0
geopy>=2.4 geopy>=2.4
geographiclib>=2.0 geographiclib>=2.0
# DINOv2-SALAD and the VPR embedders need pytorch_lightning. The local install
# keeps them in a venv (`/cache/salad-venv`) because of an old conflict; both
# import cleanly on torch 2.11 now, so a pod carries them directly and place
# recognition works without one.
pytorch_lightning>=2.0.0
pytorch_metric_learning>=2.0.0
# /v1/ocr with PaddleOCR + PP-Structure (layout, tables, reading order).
#
# paddlepaddle-gpu ships its OWN CUDA runtime, not just its own torch — the one
# stack here that cannot share anything with the main venv.
transformers>=4.35.0
pypdfium2>=4.20.0
python-doctr>=0.8.0
jsonschema>=4.0.0
# Paddle in its own venv, baked at /opt/coderai/paddleocr, from Paddle's index.
--extra-index-url https://www.paddlepaddle.org.cn/packages/stable/cu126/
paddlepaddle-gpu==3.3.1
paddleocr>=2.7.0
pypdfium2>=4.20.0
...@@ -17,3 +17,13 @@ pypdfium2>=4.20.0 ...@@ -17,3 +17,13 @@ pypdfium2>=4.20.0
python-doctr>=0.8.0 python-doctr>=0.8.0
ultralytics>=8.1.0 ultralytics>=8.1.0
jsonschema>=4.0.0 jsonschema>=4.0.0
# Surya too, in THIS venv. Its isolated venv exists because Surya used to cap
# pillow<11 against a main venv on pillow>=12 — no longer true at 0.17.1, which
# imports beside docTR on torch 2.11 with pillow 10.4 and transformers 5.17.
# Verified before adding, because that venv decision was inherited rather than
# re-checked, exactly like audiocraft's torch==2.1.0.
#
# SuryaEngine still runs as a subprocess (it is a SubprocessOcrEngine), so the
# pod points ocr.surya_venv at this interpreter rather than building one.
surya-ocr==0.17.1
# /v1/audio/diarization, /v1/audio/speaker* — pyannote + voiceprints.
#
# A SEPARATE image because pyannote.audio 3.3.2 calls
# torchaudio.AudioMetaData, which torchaudio 2.11 removed. It is not a
# conservative pin like audiocraft's was — the attribute is genuinely gone — so
# it needs an interpreter on an older torch, and that means its own venv (see
# speaker.venv-pyannote.txt) and its own image rather than 6 GB added to the
# stt image that most users pull for transcription alone.
torchaudio
transformers>=4.35.0
soundfile>=0.12.0
scipy>=1.11.0
speechbrain>=1.0.0
# pyannote on the torch it actually works with. Baked at /opt/coderai/pyannote,
# which is where _resolve_venv_dir already looks for an image-provided venv, so
# no code change and no venv build on a rented machine.
--extra-index-url https://download.pytorch.org/whl/cu124
torch==2.6.0+cu124
torchaudio==2.6.0+cu124
pyannote.audio==3.3.2
huggingface_hub<1.0
soundfile>=0.12.0
# /v1/audio/transcriptions with CrisperWhisper (verbatim, disfluency-aware).
#
# It pins transformers==4.46.3 AND torch==2.5.1 — a conflict on both axes with
# the main images, so it cannot share one.
torchaudio
soundfile>=0.12.0
scipy>=1.11.0
# CrisperWhisper's stack, baked at /opt/coderai/transformers.
--extra-index-url https://download.pytorch.org/whl/cu124
torch==2.5.1+cu124
transformers==4.46.3
accelerate>=0.33.0
soundfile>=0.12.0
# /v1/audio/transcriptions with NVIDIA NeMo Canary.
#
# nemo_toolkit[asr] brings a large stack and its own torch expectations. It gets
# an image rather than 6 GB on the stt image that most transcription uses.
torchaudio
transformers>=4.35.0
soundfile>=0.12.0
scipy>=1.11.0
# NeMo in its own venv, baked at /opt/coderai/nemo.
torch
torchaudio
nemo_toolkit[asr]>=2.0.0
cuda-python
soundfile>=0.12.0
librosa>=0.10.0
...@@ -6,3 +6,13 @@ openai-whisper>=20231117 ...@@ -6,3 +6,13 @@ openai-whisper>=20231117
vosk>=0.3.45 vosk>=0.3.45
soundfile>=0.12.0 soundfile>=0.12.0
scipy>=1.11.0 scipy>=1.11.0
# Speaker recognition — enrol/identify/verify voiceprints — is the `speaker`
# capability, which is served by THIS image. It shipped nothing for it: a
# speaker pod could boot and had no way to embed a voice. speechbrain imports
# fine on torch 2.11, so it needs no venv of its own.
#
# pyannote diarization is the part that genuinely cannot live here: it calls
# torchaudio.AudioMetaData, removed in torchaudio 2.11. That goes in the
# separate speaker image.
speechbrain>=1.0.0
# /v1/audio/speech and /v1/audio/clone with coqui XTTS-v2.
#
# coqui-tts imports isin_mps_friendly from transformers.pytorch_utils, removed
# in transformers 5.x. The main images run 5.17, and pinning 4.x there would
# break every other capability — so XTTS gets an image whose venv can be on 4.x.
torchaudio
soundfile>=0.12.0
scipy>=1.11.0
# coqui-tts on transformers 4.x, baked at /opt/coderai/TTS.
--extra-index-url https://download.pytorch.org/whl/cu124
torch==2.5.1+cu124
torchaudio==2.5.1+cu124
transformers==4.46.3
coqui-tts>=0.24.0
soundfile>=0.12.0
...@@ -644,3 +644,37 @@ def test_env_can_enable_a_subsystem_without_writing_config(tmp_path, monkeypatch ...@@ -644,3 +644,37 @@ def test_env_can_enable_a_subsystem_without_writing_config(tmp_path, monkeypatch
on_disk = json.loads((tmp_path / "config.json").read_text()) on_disk = json.loads((tmp_path / "config.json").read_text())
assert on_disk.get("ocr", {}).get("enabled") is not True, \ assert on_disk.get("ocr", {}).get("enabled") is not True, \
"the env override leaked into config.json" "the env override leaked into config.json"
def test_a_capability_whose_stack_cannot_share_gets_its_own_image():
"""`speaker` used to point at the STT image on the assumption it was 'the
STT deps plus a bit'. That image shipped nothing for diarization or
voiceprints, so a speaker pod booted with no way to embed a voice."""
from codai.api.runpod_worker import (default_capability_image,
PUBLISHED_CAPABILITY_IMAGES)
assert default_capability_image("speaker").endswith("coderai-speaker:latest")
for name in ("speaker", "tts-xtts", "stt-nemo", "stt-crisper", "ocr-paddle"):
assert name in PUBLISHED_CAPABILITY_IMAGES, name
# Capabilities that genuinely DO share a stack still do.
assert default_capability_image("rerank").endswith("coderai-embeddings:latest")
assert default_capability_image("stems").endswith("coderai-audio:latest")
def test_a_pod_is_told_where_its_baked_venvs_are():
"""Otherwise the worker tries to build one, on a machine rented by the
second, installing what the image already contains."""
from codai.api.runpod_worker import pod_plan, parse_model_runpod
mcfg = parse_model_runpod({"engine": "coderai", "image": "x:1"})
env = pod_plan(mcfg, "speaker", entry={"path": "pyannote/speaker-diarization-3.1",
"model_type": "audio_models"})["env"]
assert env["CODERAI_PYANNOTE_VENV"] == "/opt/coderai/venvs/pyannote"
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
# pod's own interpreter rather than a venv that would only duplicate it.
env = pod_plan(mcfg, "ocr", entry={"path": "surya",
"model_type": "ocr_models"})["env"]
assert env["CODERAI_OCR_SURYA_VENV"] == "/usr/local"
...@@ -598,7 +598,10 @@ def test_capability_pods_default_to_the_published_images(): ...@@ -598,7 +598,10 @@ def test_capability_pods_default_to_the_published_images():
assert default_capability_image("video") == "ghcr.io/nextime/coderai-video:latest" assert default_capability_image("video") == "ghcr.io/nextime/coderai-video:latest"
# Capabilities that ride another profile's dependency set. # Capabilities that ride another profile's dependency set.
assert default_capability_image("rerank").endswith("coderai-embeddings:latest") assert default_capability_image("rerank").endswith("coderai-embeddings:latest")
assert default_capability_image("speaker").endswith("coderai-stt:latest") # `speaker` had its own image once it turned out the STT image shipped
# nothing for diarization or voiceprints — see the speaker test in
# test_burst_placement.py.
assert default_capability_image("speaker").endswith("coderai-speaker:latest")
assert default_capability_image("stems").endswith("coderai-audio:latest") assert default_capability_image("stems").endswith("coderai-audio:latest")
# No published image: say so rather than default to a tag that 404s minutes # No published image: say so rather than default to a tag that 404s minutes
# into a pod boot. # into a pod boot.
......
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