| """Resolves the active LLM (per model_id) + image backends based on runtime |
| config. |
| |
| LLM backends are cached per-model_id so a character that gets its model |
| swapped doesn't trigger a reload, and so multiple characters on the same |
| model share one instance. The first call for a given model_id triggers the |
| GGUF download/load (Real) or instantiates a canned-style cycle (Mock). |
| |
| The image backend is mock-only: Flux 2 was dropped from the pipeline (the |
| frontend never called /generate_background and the cold-load was eating |
| into the ZeroGPU quota). MockImageBackend renders a Pillow gradient — fine |
| as a placeholder if the endpoint ever gets called. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
|
|
| from game.models import DEFAULT_MODEL_ID, get_spec |
|
|
| from . import config |
| from .interfaces import ImageBackend, LLMBackend |
|
|
| _llms: dict[str, LLMBackend] = {} |
| _image: ImageBackend | None = None |
|
|
|
|
| def _cache_key(model_id: str) -> str: |
| """GPU and CPU Llama instances are loaded with different `n_gpu_layers` |
| and can't be reused across modes. Cache them under separate keys so the |
| CPU-fallback path (parent process, TOWNLET_FORCE_CPU=1) and the GPU |
| path (forked workers, no env var) don't collide. |
| """ |
| mode = "cpu" if os.getenv("TOWNLET_FORCE_CPU") == "1" else "gpu" |
| return f"{model_id}::{mode}" |
|
|
|
|
| def get_llm(model_id: str = DEFAULT_MODEL_ID) -> LLMBackend: |
| key = _cache_key(model_id) |
| if key not in _llms: |
| get_spec(model_id) |
| if config.USE_REAL_MODELS: |
| from .real import RealLLMBackend |
|
|
| _llms[key] = RealLLMBackend(model_id) |
| else: |
| from .mock import MockLLMBackend |
|
|
| _llms[key] = MockLLMBackend(model_id) |
| return _llms[key] |
|
|
|
|
| def get_image() -> ImageBackend: |
| global _image |
| if _image is None: |
| from .mock import MockImageBackend |
|
|
| _image = MockImageBackend() |
| return _image |
|
|