token-holdem / modal_inference.py
Girgie's picture
Deploy Token Hold'em Space
81c1867 verified
Raw
History Blame Contribute Delete
34.5 kB
import json
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import lru_cache
from pathlib import Path
from typing import Any
import modal
MODEL_CACHE_DIR = os.getenv("TOKEN_HOLDEM_MODEL_CACHE_DIR", "/cache/huggingface")
HF_CACHE_ENV = {
"HF_HOME": MODEL_CACHE_DIR,
"TRANSFORMERS_CACHE": MODEL_CACHE_DIR,
"HF_HUB_CACHE": MODEL_CACHE_DIR,
"HUGGINGFACE_HUB_CACHE": MODEL_CACHE_DIR,
}
for name, value in HF_CACHE_ENV.items():
os.environ.setdefault(name, value)
def _env_flag(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def _modal_log(message: str, **fields: Any) -> None:
payload = {
"message": message,
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
**fields,
}
print(json.dumps(payload, ensure_ascii=True, default=str), flush=True)
APP_NAME = os.getenv("TOKEN_HOLDEM_MODAL_APP_NAME", "token-holdem-inference")
DEFAULT_GPU = os.getenv("TOKEN_HOLDEM_MODAL_GPU", "L40S") or None
HEAVY_GPU = os.getenv("TOKEN_HOLDEM_MODAL_HEAVY_GPU", "A100-80GB") or DEFAULT_GPU
HF_SECRET_NAME = os.getenv("TOKEN_HOLDEM_MODAL_HF_SECRET_NAME", "token-holdem-hf-token")
MODAL_TIMEOUT_SECONDS = int(os.getenv("TOKEN_HOLDEM_MODAL_TIMEOUT_SECONDS", "300"))
DEMO_MODE = _env_flag("TOKEN_HOLDEM_MODAL_DEMO_MODE", True)
DEFAULT_SCALEDOWN_WINDOW_SECONDS = 1800 if DEMO_MODE else 600
SCALEDOWN_WINDOW_SECONDS = int(os.getenv("TOKEN_HOLDEM_MODAL_SCALEDOWN_SECONDS", str(DEFAULT_SCALEDOWN_WINDOW_SECONDS)))
MIN_CONTAINERS = int(os.getenv("TOKEN_HOLDEM_MODAL_MIN_CONTAINERS", "0")) or None
GGUF_DECISION_MAX_TOKENS = int(os.getenv("TOKEN_HOLDEM_GGUF_DECISION_TOKENS", "96"))
GGUF_TALK_MAX_TOKENS = int(os.getenv("TOKEN_HOLDEM_GGUF_TALK_TOKENS", "24"))
hf_cache = modal.Volume.from_name("token-holdem-hf-cache", create_if_missing=True)
GGUF_MODEL_FILES = {
"nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF": "NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf",
"lm-kit/qwen-3-0.6b-instruct-gguf": "Qwen3-0.6B-Q4_K_M.gguf",
"unsloth/North-Mini-Code-1.0-GGUF": "North-Mini-Code-1.0-UD-Q4_K_M.gguf",
"bartowski/c4ai-command-r7b-12-2024-GGUF": "c4ai-command-r7b-12-2024-Q4_K_M.gguf",
"TheBloke/Mistral-7B-Instruct-v0.2-GGUF": "mistral-7b-instruct-v0.2.Q4_K_M.gguf",
}
MULTIMODAL_PROCESSOR_MODELS = {"google/gemma-4-12B-it"}
image = (
modal.Image.debian_slim(python_version="3.13")
.env(HF_CACHE_ENV)
.uv_sync()
.add_local_python_source("token_holdem")
)
app = modal.App(APP_NAME, image=image, volumes={MODEL_CACHE_DIR: hf_cache})
worker_options = {
"gpu": DEFAULT_GPU,
"timeout": MODAL_TIMEOUT_SECONDS,
"scaledown_window": SCALEDOWN_WINDOW_SECONDS,
"secrets": [modal.Secret.from_name(HF_SECRET_NAME)],
"min_containers": MIN_CONTAINERS,
}
heavy_worker_options = {**worker_options, "gpu": HEAVY_GPU}
cache_setup_options = {
"timeout": max(MODAL_TIMEOUT_SECONDS, 1800),
"scaledown_window": 60,
"secrets": [modal.Secret.from_name(HF_SECRET_NAME)],
}
_modal_log(
"modal_container_start",
app_name=APP_NAME,
cache_dir=MODEL_CACHE_DIR,
demo_mode=DEMO_MODE,
scaledown_window_seconds=SCALEDOWN_WINDOW_SECONDS,
default_gpu=DEFAULT_GPU,
heavy_gpu=HEAVY_GPU,
)
def _commit_model_cache() -> None:
start = time.perf_counter()
hf_cache.commit()
_modal_log("modal_cache_commit", elapsed_seconds=round(time.perf_counter() - start, 3), cache_dir=MODEL_CACHE_DIR)
def _snapshot_cache_exists(model_id: str, filename: str | None = None) -> bool:
repo_dir = Path(MODEL_CACHE_DIR) / f"models--{model_id.replace('/', '--')}"
snapshots_dir = repo_dir / "snapshots"
if not snapshots_dir.exists():
return False
snapshots = [path for path in snapshots_dir.iterdir() if path.is_dir()]
if filename:
return any((snapshot / filename).exists() for snapshot in snapshots)
return any(any(snapshot.iterdir()) for snapshot in snapshots)
def _download_model_snapshot(model_id: str) -> dict[str, Any]:
from huggingface_hub import snapshot_download
from token_holdem.model_runtime import requires_gguf_runtime
filename = GGUF_MODEL_FILES.get(model_id) if requires_gguf_runtime(model_id) else None
if requires_gguf_runtime(model_id) and not filename:
raise ValueError(f"No GGUF filename configured for {model_id}")
cache_hit_before = _snapshot_cache_exists(model_id, filename)
start = time.perf_counter()
_modal_log(
"modal_model_snapshot_download_start",
model_id=model_id,
cache_dir=MODEL_CACHE_DIR,
cache_hit_before=cache_hit_before,
allow_patterns=[filename] if filename else None,
)
try:
snapshot_path = snapshot_download(
repo_id=model_id,
cache_dir=MODEL_CACHE_DIR,
allow_patterns=[filename] if filename else None,
)
except Exception as exc:
_modal_log(
"modal_model_snapshot_download_error",
model_id=model_id,
error_type=exc.__class__.__name__,
error=str(exc),
elapsed_seconds=round(time.perf_counter() - start, 3),
)
raise
_commit_model_cache()
elapsed = time.perf_counter() - start
_modal_log(
"modal_model_snapshot_download_complete",
model_id=model_id,
cache_state="hit" if cache_hit_before else "downloaded",
snapshot_path=snapshot_path,
elapsed_seconds=round(elapsed, 3),
)
return {
"model_id": model_id,
"cache_hit_before": cache_hit_before,
"snapshot_path": snapshot_path,
"elapsed_seconds": elapsed,
}
def _profiles_for_modal_config(configured: str | None = None) -> list[Any]:
from token_holdem.agents import ROSTER
from token_holdem.model_runtime import configured_modal_model_names
enabled = configured_modal_model_names(configured)
return [profile for profile in ROSTER if profile.name in enabled]
@lru_cache(maxsize=None)
def _load_model(model_id: str) -> tuple[Any, Any]:
from transformers import AutoModelForCausalLM, AutoTokenizer
load_start = time.perf_counter()
cache_hit_before = _snapshot_cache_exists(model_id)
_modal_log("modal_model_cache_status", model_id=model_id, cache_hit_before=cache_hit_before, cache_dir=MODEL_CACHE_DIR)
try:
tokenizer_start = time.perf_counter()
tokenizer = AutoTokenizer.from_pretrained(
model_id,
trust_remote_code=True,
cache_dir=MODEL_CACHE_DIR,
)
_modal_log(
"modal_tokenizer_load_complete",
model_id=model_id,
elapsed_seconds=round(time.perf_counter() - tokenizer_start, 3),
)
model_start = time.perf_counter()
model_kwargs: dict[str, Any] = {
"dtype": "auto",
"device_map": "auto",
"trust_remote_code": True,
"low_cpu_mem_usage": True,
"cache_dir": MODEL_CACHE_DIR,
}
if model_id == "openai/gpt-oss-20b":
model_kwargs["device_map"] = {"": "cuda:0"}
model = AutoModelForCausalLM.from_pretrained(
model_id,
**model_kwargs,
)
model.eval()
_modal_log(
"modal_model_load_to_gpu_complete",
model_id=model_id,
elapsed_seconds=round(time.perf_counter() - model_start, 3),
device=str(getattr(model, "device", "device_map")),
)
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
_commit_model_cache()
_modal_log(
"modal_model_load_complete",
model_id=model_id,
cache_state="hit" if cache_hit_before else "downloaded",
elapsed_seconds=round(time.perf_counter() - load_start, 3),
)
return model, tokenizer
except Exception as exc:
_modal_log(
"modal_model_load_error",
model_id=model_id,
error_type=exc.__class__.__name__,
error=str(exc),
elapsed_seconds=round(time.perf_counter() - load_start, 3),
)
raise
@lru_cache(maxsize=None)
def _load_multimodal_model(model_id: str) -> tuple[Any, Any]:
from transformers import AutoModelForMultimodalLM, AutoProcessor
load_start = time.perf_counter()
cache_hit_before = _snapshot_cache_exists(model_id)
_modal_log("modal_model_cache_status", model_id=model_id, cache_hit_before=cache_hit_before, cache_dir=MODEL_CACHE_DIR)
try:
processor_start = time.perf_counter()
processor = AutoProcessor.from_pretrained(
model_id,
trust_remote_code=True,
cache_dir=MODEL_CACHE_DIR,
)
_modal_log(
"modal_tokenizer_load_complete",
model_id=model_id,
elapsed_seconds=round(time.perf_counter() - processor_start, 3),
)
model_start = time.perf_counter()
model = AutoModelForMultimodalLM.from_pretrained(
model_id,
dtype="auto",
device_map="auto",
trust_remote_code=True,
low_cpu_mem_usage=True,
cache_dir=MODEL_CACHE_DIR,
)
model.eval()
_modal_log(
"modal_model_load_to_gpu_complete",
model_id=model_id,
elapsed_seconds=round(time.perf_counter() - model_start, 3),
device=str(getattr(model, "device", "device_map")),
)
_commit_model_cache()
_modal_log(
"modal_model_load_complete",
model_id=model_id,
cache_state="hit" if cache_hit_before else "downloaded",
elapsed_seconds=round(time.perf_counter() - load_start, 3),
)
return model, processor
except Exception as exc:
_modal_log(
"modal_model_load_error",
model_id=model_id,
error_type=exc.__class__.__name__,
error=str(exc),
elapsed_seconds=round(time.perf_counter() - load_start, 3),
)
raise
@lru_cache(maxsize=None)
def _load_gguf_model(model_id: str) -> Any:
from huggingface_hub import hf_hub_download
from llama_cpp import Llama
load_start = time.perf_counter()
filename = GGUF_MODEL_FILES.get(model_id)
if not filename:
raise ValueError(f"No GGUF filename configured for {model_id}")
cache_hit_before = _snapshot_cache_exists(model_id, filename)
_modal_log(
"modal_model_cache_status",
model_id=model_id,
gguf_filename=filename,
cache_hit_before=cache_hit_before,
cache_dir=MODEL_CACHE_DIR,
)
try:
download_start = time.perf_counter()
model_path = hf_hub_download(
repo_id=model_id,
filename=filename,
cache_dir=MODEL_CACHE_DIR,
)
_modal_log(
"modal_model_download_complete",
model_id=model_id,
cache_state="hit" if cache_hit_before else "downloaded",
elapsed_seconds=round(time.perf_counter() - download_start, 3),
model_path=model_path,
)
_commit_model_cache()
model_start = time.perf_counter()
model = Llama(
model_path=model_path,
n_ctx=int(os.getenv("TOKEN_HOLDEM_GGUF_CONTEXT", "4096")),
n_gpu_layers=int(os.getenv("TOKEN_HOLDEM_GGUF_GPU_LAYERS", "-1")),
verbose=False,
)
_modal_log(
"modal_model_load_to_gpu_complete",
model_id=model_id,
elapsed_seconds=round(time.perf_counter() - model_start, 3),
)
_modal_log(
"modal_model_load_complete",
model_id=model_id,
cache_state="hit" if cache_hit_before else "downloaded",
elapsed_seconds=round(time.perf_counter() - load_start, 3),
)
return model
except Exception as exc:
_modal_log(
"modal_model_load_error",
model_id=model_id,
error_type=exc.__class__.__name__,
error=str(exc),
elapsed_seconds=round(time.perf_counter() - load_start, 3),
)
raise
def _format_chat_prompt(tokenizer: Any, prompt: str) -> str:
if getattr(tokenizer, "chat_template", None):
messages = [{"role": "user", "content": prompt}]
try:
return tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
return tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
return f"{prompt}\n\nAssistant:"
def _format_multimodal_prompt(processor: Any, prompt: str) -> str:
if getattr(processor, "apply_chat_template", None):
messages = [{"role": "user", "content": [{"type": "text", "text": prompt}]}]
try:
return processor.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
return processor.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
tokenizer = getattr(processor, "tokenizer", None)
if tokenizer is not None and getattr(tokenizer, "chat_template", None):
messages = [{"role": "user", "content": prompt}]
try:
return tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
return tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
return f"{prompt}\n\nAssistant:"
def _move_inputs_to_device(inputs: Any, device: Any) -> Any:
if hasattr(inputs, "to"):
return inputs.to(device)
return {key: value.to(device) if hasattr(value, "to") else value for key, value in inputs.items()}
def _decode_processor_output(processor: Any, output: Any) -> str:
tokenizer = getattr(processor, "tokenizer", None)
decoder = tokenizer if tokenizer is not None else processor
return decoder.decode(output, skip_special_tokens=True)
class _FirstTokenTimer:
def __init__(self, started_at: float):
self.started_at = started_at
self.first_token_seconds: float | None = None
self._saw_prompt = False
def put(self, value: Any) -> None:
if not self._saw_prompt:
self._saw_prompt = True
return
if self.first_token_seconds is None:
self.first_token_seconds = time.perf_counter() - self.started_at
def end(self) -> None:
pass
def _log_generation_complete(runtime_family: str, started_at: float, first_token_seconds: float | None) -> None:
elapsed = time.perf_counter() - started_at
_modal_log(
"modal_generation_complete",
runtime_family=runtime_family,
first_token_seconds=round(first_token_seconds if first_token_seconds is not None else elapsed, 3),
total_generation_seconds=round(elapsed, 3),
)
def _generate_text(
model: Any,
tokenizer: Any,
prompt: str,
max_new_tokens: int,
temperature: float,
*,
json_prefix: bool = False,
deterministic: bool = False,
) -> str:
import torch
formatted_prompt = _format_chat_prompt(tokenizer, prompt)
if json_prefix:
formatted_prompt += "{"
inputs = tokenizer(formatted_prompt, return_tensors="pt").to(model.device)
generation_start = time.perf_counter()
first_token_timer = _FirstTokenTimer(generation_start)
generation_kwargs: dict[str, Any] = {
**inputs,
"max_new_tokens": max_new_tokens,
"do_sample": not deterministic,
"pad_token_id": tokenizer.eos_token_id,
"streamer": first_token_timer,
}
if not deterministic:
generation_kwargs.update({"temperature": temperature, "top_p": 0.9})
with torch.inference_mode():
output = model.generate(**generation_kwargs)
_log_generation_complete("causal", generation_start, first_token_timer.first_token_seconds)
decoded = tokenizer.decode(output[0][inputs["input_ids"].shape[-1] :], skip_special_tokens=True)
return "{" + decoded if json_prefix else decoded
def _generate_multimodal_text(
model: Any,
processor: Any,
prompt: str,
max_new_tokens: int,
temperature: float,
*,
json_prefix: bool = False,
deterministic: bool = False,
) -> str:
import torch
formatted_prompt = _format_multimodal_prompt(processor, prompt)
if json_prefix:
formatted_prompt += "{"
inputs = _move_inputs_to_device(processor(text=formatted_prompt, return_tensors="pt"), model.device)
tokenizer = getattr(processor, "tokenizer", None)
eos_token_id = getattr(tokenizer, "eos_token_id", None)
generation_start = time.perf_counter()
first_token_timer = _FirstTokenTimer(generation_start)
generation_kwargs: dict[str, Any] = {
**inputs,
"max_new_tokens": max_new_tokens,
"do_sample": not deterministic,
"pad_token_id": eos_token_id,
"streamer": first_token_timer,
}
if not deterministic:
generation_kwargs.update({"temperature": temperature, "top_p": 0.9})
with torch.inference_mode():
output = model.generate(**generation_kwargs)
_log_generation_complete("multimodal", generation_start, first_token_timer.first_token_seconds)
decoded = _decode_processor_output(processor, output[0][inputs["input_ids"].shape[-1] :])
return "{" + decoded if json_prefix else decoded
def _generate_gguf_text(
model: Any,
prompt: str,
max_new_tokens: int,
temperature: float,
*,
json_prefix: bool = False,
deterministic: bool = False,
) -> str:
generation_start = time.perf_counter()
first_token_seconds: float | None = None
chunks: list[str] = []
formatted_prompt = f"{prompt}\n{{" if json_prefix else prompt
output = model(
formatted_prompt,
max_tokens=max_new_tokens,
temperature=0.0 if deterministic else temperature,
top_p=0.9,
stop=["\n\nUser:", "\n\nVisible state:", "\ntable_talk=", "table_talk=", "\n```"],
stream=True,
)
for chunk in output:
text = str(chunk["choices"][0].get("text", ""))
if text and first_token_seconds is None:
first_token_seconds = time.perf_counter() - generation_start
chunks.append(text)
_log_generation_complete("gguf", generation_start, first_token_seconds)
decoded = "".join(chunks).strip()
return "{" + decoded if json_prefix else decoded
def _requires_multimodal_processor(model_id: str) -> bool:
return model_id in MULTIMODAL_PROCESSOR_MODELS
def _generate_loaded_text(
model: Any,
tokenizer_or_processor: Any,
runtime_family: str,
prompt: str,
*,
max_new_tokens: int,
temperature: float,
json_prefix: bool = False,
deterministic: bool = False,
) -> str:
if runtime_family == "gguf":
return _generate_gguf_text(
model,
prompt,
max_new_tokens=max_new_tokens,
temperature=temperature,
json_prefix=json_prefix,
deterministic=deterministic,
)
if runtime_family == "multimodal":
return _generate_multimodal_text(
model,
tokenizer_or_processor,
prompt,
max_new_tokens=max_new_tokens,
temperature=temperature,
json_prefix=json_prefix,
deterministic=deterministic,
)
return _generate_text(
model,
tokenizer_or_processor,
prompt,
max_new_tokens=max_new_tokens,
temperature=temperature,
json_prefix=json_prefix,
deterministic=deterministic,
)
def _build_decision_repair_prompt(original_prompt: str, invalid_output: str, legal_actions: dict[str, Any]) -> str:
return f"""{original_prompt}
Your previous answer was invalid because it was not a single legal JSON object.
Previous answer:
{invalid_output[:900]}
Return exactly one compact JSON object now.
Allowed actions: {legal_actions['actions']}
Raise presets: {legal_actions['raise_presets']}
Use this schema only:
{{"action":"call","amount":0,"reasoning_hint":"brief reason"}}
No thinking. No markdown. No surrounding text.
"""
def _run_agent_decision_impl(
game_state: dict[str, Any],
model_name: str,
persona: str,
model_id: str,
legal_actions: dict[str, Any],
prompt: str,
) -> dict[str, Any]:
try:
from token_holdem.model_runtime import requires_gguf_runtime
is_gguf = requires_gguf_runtime(model_id)
is_multimodal = _requires_multimodal_processor(model_id)
if is_gguf:
model = _load_gguf_model(model_id)
tokenizer_or_processor = None
runtime_family = "gguf"
elif is_multimodal:
model, tokenizer_or_processor = _load_multimodal_model(model_id)
runtime_family = "multimodal"
else:
model, tokenizer_or_processor = _load_model(model_id)
runtime_family = "causal"
return _run_loaded_agent_decision_impl(
game_state,
model_name,
persona,
model_id,
legal_actions,
prompt,
model,
tokenizer_or_processor,
runtime_family,
)
except Exception as exc: # noqa: BLE001 - the local adapter converts this into a visible unavailable state.
return {
"action": None,
"bet_amount": None,
"explanation": "",
"commentary": "",
"raw_model_output": "",
"error": f"{exc.__class__.__name__}: {exc}",
}
@app.cls(**worker_options)
class CausalModelWorker:
model_id: str = modal.parameter()
model: Any = modal.parameter(init=False)
tokenizer: Any = modal.parameter(init=False)
@modal.enter()
def load_model(self) -> None:
self.model, self.tokenizer = _load_model(self.model_id)
@modal.method()
def warmup(self, model_name: str = "") -> dict[str, Any]:
return {
"model_name": model_name,
"model_id": self.model_id,
"runtime_family": "causal",
"loaded": self.model is not None and self.tokenizer is not None,
"cache_dir": MODEL_CACHE_DIR,
}
@modal.method()
def decide(
self,
game_state: dict[str, Any],
model_name: str,
persona: str,
legal_actions: dict[str, Any],
prompt: str,
) -> dict[str, Any]:
return _run_loaded_agent_decision_impl(
game_state,
model_name,
persona,
self.model_id,
legal_actions,
prompt,
self.model,
self.tokenizer,
"causal",
)
@app.cls(**heavy_worker_options)
class HeavyCausalModelWorker:
model_id: str = modal.parameter()
model: Any = modal.parameter(init=False)
tokenizer: Any = modal.parameter(init=False)
@modal.enter()
def load_model(self) -> None:
self.model, self.tokenizer = _load_model(self.model_id)
@modal.method()
def warmup(self, model_name: str = "") -> dict[str, Any]:
return {
"model_name": model_name,
"model_id": self.model_id,
"runtime_family": "heavy_causal",
"loaded": self.model is not None and self.tokenizer is not None,
"cache_dir": MODEL_CACHE_DIR,
"gpu": HEAVY_GPU,
}
@modal.method()
def decide(
self,
game_state: dict[str, Any],
model_name: str,
persona: str,
legal_actions: dict[str, Any],
prompt: str,
) -> dict[str, Any]:
return _run_loaded_agent_decision_impl(
game_state,
model_name,
persona,
self.model_id,
legal_actions,
prompt,
self.model,
self.tokenizer,
"causal",
)
@app.cls(**worker_options)
class MultimodalModelWorker:
model_id: str = modal.parameter()
model: Any = modal.parameter(init=False)
processor: Any = modal.parameter(init=False)
@modal.enter()
def load_model(self) -> None:
self.model, self.processor = _load_multimodal_model(self.model_id)
@modal.method()
def warmup(self, model_name: str = "") -> dict[str, Any]:
return {
"model_name": model_name,
"model_id": self.model_id,
"runtime_family": "multimodal",
"loaded": self.model is not None and self.processor is not None,
"cache_dir": MODEL_CACHE_DIR,
}
@modal.method()
def decide(
self,
game_state: dict[str, Any],
model_name: str,
persona: str,
legal_actions: dict[str, Any],
prompt: str,
) -> dict[str, Any]:
return _run_loaded_agent_decision_impl(
game_state,
model_name,
persona,
self.model_id,
legal_actions,
prompt,
self.model,
self.processor,
"multimodal",
)
@app.cls(**worker_options)
class GgufModelWorker:
model_id: str = modal.parameter()
model: Any = modal.parameter(init=False)
@modal.enter()
def load_model(self) -> None:
self.model = _load_gguf_model(self.model_id)
@modal.method()
def warmup(self, model_name: str = "") -> dict[str, Any]:
return {
"model_name": model_name,
"model_id": self.model_id,
"runtime_family": "gguf",
"loaded": self.model is not None,
"cache_dir": MODEL_CACHE_DIR,
}
@modal.method()
def decide(
self,
game_state: dict[str, Any],
model_name: str,
persona: str,
legal_actions: dict[str, Any],
prompt: str,
) -> dict[str, Any]:
return _run_loaded_agent_decision_impl(
game_state,
model_name,
persona,
self.model_id,
legal_actions,
prompt,
self.model,
None,
"gguf",
)
MODAL_WORKER_CLASSES = {
"CausalModelWorker": CausalModelWorker,
"HeavyCausalModelWorker": HeavyCausalModelWorker,
"MultimodalModelWorker": MultimodalModelWorker,
"GgufModelWorker": GgufModelWorker,
}
def _run_loaded_agent_decision_impl(
game_state: dict[str, Any],
model_name: str,
persona: str,
model_id: str,
legal_actions: dict[str, Any],
prompt: str,
model: Any,
tokenizer_or_processor: Any,
runtime_family: str,
) -> dict[str, Any]:
from token_holdem.agents import fallback_decide
from token_holdem.agents import profile_by_name
from token_holdem.model_runtime import (
apply_poker_sanity_guard,
first_valid_decision,
template_table_talk,
)
try:
try:
profile = profile_by_name(model_name)
except StopIteration:
from token_holdem.agents import AgentProfile
profile = AgentProfile(model_name, model_id, persona, 0.5, 0.1, ("The candlelight keeps me thinking.",))
decision_tokens = GGUF_DECISION_MAX_TOKENS if runtime_family == "gguf" else 192
decision_text = _generate_loaded_text(
model,
tokenizer_or_processor,
runtime_family,
prompt,
max_new_tokens=decision_tokens,
temperature=0.0,
json_prefix=True,
deterministic=True,
)
decision = first_valid_decision(decision_text, legal_actions)
repair_text = ""
if decision is None:
repair_text = _generate_loaded_text(
model,
tokenizer_or_processor,
runtime_family,
_build_decision_repair_prompt(prompt, decision_text, legal_actions),
max_new_tokens=GGUF_DECISION_MAX_TOKENS if runtime_family == "gguf" else 96,
temperature=0.0,
json_prefix=True,
deterministic=True,
)
decision = first_valid_decision(repair_text, legal_actions)
if decision is None:
decision = apply_poker_sanity_guard(fallback_decide(profile, game_state, seed=game_state.get("seed")), game_state)
commentary = template_table_talk(profile, decision["action"], game_state)
return {
"action": decision["action"],
"bet_amount": int(decision.get("amount") or 0),
"explanation": "model decision JSON invalid after repair attempt; used persona fallback action",
"commentary": commentary,
"raw_model_output": f"decision={decision_text[:800]}\nrepair={repair_text[:500]}",
"error": None,
}
decision = apply_poker_sanity_guard(decision, game_state)
commentary = template_table_talk(profile, decision["action"], game_state)
raw_model_output = f"decision={decision_text[:800]}"
if repair_text:
raw_model_output += f"\nrepair={repair_text[:500]}"
return {
"action": decision["action"],
"bet_amount": int(decision.get("amount") or 0),
"explanation": decision.get("reasoning_hint", ""),
"commentary": commentary,
"raw_model_output": raw_model_output,
"error": None,
}
except Exception as exc: # noqa: BLE001 - the local adapter converts this into a visible unavailable state.
return {
"action": None,
"bet_amount": None,
"explanation": "",
"commentary": "",
"raw_model_output": "",
"error": f"{exc.__class__.__name__}: {exc}",
}
@app.function(**cache_setup_options)
def predownload_model_snapshot(model_name: str, model_id: str) -> dict[str, Any]:
return {"model_name": model_name, **_download_model_snapshot(model_id)}
def _collect_spawned_calls(spawned_calls: list[tuple[str, float, Any]]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=max(1, len(spawned_calls))) as executor:
futures = {
executor.submit(call.get, timeout=max(MODAL_TIMEOUT_SECONDS, 1800)): (model_name, start)
for model_name, start, call in spawned_calls
}
for future in as_completed(futures):
model_name, start = futures[future]
result = future.result()
result["elapsed_seconds"] = round(time.perf_counter() - start, 3)
results.append(result)
_modal_log("modal_parallel_call_complete", **{"model_name": model_name, **result})
return sorted(results, key=lambda result: result.get("model_name", ""))
@app.function(**worker_options)
def run_agent_decision(
game_state: dict[str, Any],
model_name: str,
persona: str,
model_id: str,
legal_actions: dict[str, Any],
prompt: str,
) -> dict[str, Any]:
return _run_agent_decision_impl(
game_state,
model_name,
persona,
model_id,
legal_actions,
prompt,
)
@app.local_entrypoint()
def setup_cache(model_names: str = "default") -> None:
configured = None if model_names == "default" else model_names
spawned_calls = []
for profile in _profiles_for_modal_config(configured):
_modal_log("modal_cache_setup_spawn", model_name=profile.name, model_id=profile.model_id)
spawned_calls.append((profile.name, time.perf_counter(), predownload_model_snapshot.spawn(profile.name, profile.model_id)))
results = _collect_spawned_calls(spawned_calls)
print(json.dumps(results, indent=2, default=str))
@app.local_entrypoint()
def warmup_demo(model_names: str = "default") -> None:
from token_holdem.model_runtime import modal_worker_class_name
configured = None if model_names == "default" else model_names
results = []
spawned_calls = []
for profile in _profiles_for_modal_config(configured):
Worker = MODAL_WORKER_CLASSES[modal_worker_class_name(profile.model_id)]
_modal_log("modal_demo_warmup_start", model_name=profile.name, model_id=profile.model_id)
spawned_calls.append((profile.name, time.perf_counter(), Worker(model_id=profile.model_id).warmup.spawn(profile.name)))
results = _collect_spawned_calls(spawned_calls)
for result in results:
_modal_log("modal_demo_warmup_complete", **result)
print(json.dumps(results, indent=2, default=str))
@app.local_entrypoint()
def smoke(model_name: str = "Gemma") -> None:
from token_holdem.agents import profile_by_name
from token_holdem.model_runtime import build_prompt
profile = profile_by_name(model_name)
state = {
"hand_no": 1,
"street": "preflop",
"hole_cards": ["As", "Kd"],
"community_cards": [],
"stack": 1000,
"pot": 30,
"legal": {
"actions": ["fold", "call", "raise", "all_in"],
"to_call": 20,
"raise_presets": {"min": 40, "half_pot": 80, "pot": 140, "all_in": 1000},
},
"history": [],
"recent_chats": [],
"seed": 123,
"session_id": "modal-smoke",
"hand_id": "modal-smoke-h001",
"orbit_id": "modal-smoke-o01",
}
from token_holdem.model_runtime import modal_worker_class_name
Worker = MODAL_WORKER_CLASSES[modal_worker_class_name(profile.model_id)]
result = Worker(model_id=profile.model_id).decide.remote(
state,
profile.name,
profile.persona,
state["legal"],
build_prompt(profile, state),
)
print(result)