18 Commits

Author SHA1 Message Date
e6b204f23b i have no idea 2026-04-29 14:41:32 +00:00
3ce154170d they already fixed the thing 2026-04-29 12:51:04 +00:00
bda7dc7d28 moar deepseek fixes 2026-04-29 10:49:32 +00:00
fa939de3b9 moar deepseek fixes 2026-04-29 10:48:24 +00:00
b594775d07 moar deps 2026-04-29 10:24:49 +00:00
14df07de28 new vllm support 2026-04-29 09:58:58 +00:00
6c89a6d6fd new vllm support 2026-04-29 09:57:29 +00:00
5f8682d00a new vllm support 2026-04-29 09:53:18 +00:00
4556945525 fix: V4 parser instance attrs shadowed by V3.2 __init__
V3.2 __init__ sets self.tool_call_start_token and self.tool_call_end_token
as instance attributes with function_calls tag values, shadowing the V4
class-level attributes that use tool_calls tags. Re-assign after super().
2026-04-25 23:14:37 +00:00
8520a27c06 moar 2026-04-25 13:06:33 +00:00
d8ec84210f aye carumba2 2026-04-25 08:20:32 +00:00
e5ab680045 aye carumba 2026-04-25 07:55:17 +00:00
05cbcf71f4 freshen it all up 2026-04-25 07:33:54 +00:00
6de9103325 fix deepseeks bullshit parser 2026-04-25 02:28:09 +00:00
01c1ab46db fix deepseeks bullshit parser 2026-04-25 02:27:30 +00:00
7a27871402 fix deepseeks bullshit parser 2026-04-25 02:25:04 +00:00
4381ca1e3b deepseek 2026-04-24 11:01:15 +00:00
4bd3b65fda deepseek 2026-04-24 10:43:58 +00:00
5 changed files with 248 additions and 323 deletions

View File

@@ -1,8 +1,9 @@
#FROM vllm/vllm-openai:v0.19.0-cu130
FROM vllm/vllm-openai:cu130-nightly-x86_64
#FROM vllm/vllm-openai:deepseekv4-cu130
FROM vllm/vllm-openai:v0.20.0-cu130
# Fix the broken ass nightly build that forgot to include pandas
RUN pip install --no-cache-dir pandas
#RUN pip install --no-cache-dir pandas
# Install LMCache for KV cache offloading / sharing across nodes
# Build with system CUDA 13.0 for Blackwell (B200)
@@ -12,6 +13,7 @@ RUN apt-get update && apt-get install -y git \
libcublas-dev-13-0 \
libcurand-dev-13-0 \
libcufft-dev-13-0 \
cuda-nvrtc-dev-13-0 \
libnvjitlink-dev-13-0 && \
git clone https://github.com/biondizzle/LMCache.git /tmp/lmcache && \
cd /tmp/lmcache && \
@@ -19,36 +21,29 @@ RUN apt-get update && apt-get install -y git \
CUDA_HOME=/usr/local/cuda \
TORCH_CUDA_ARCH_LIST="10.0" \
pip install --no-cache-dir --no-build-isolation . && \
rm -rf /tmp/lmcache && export CACHE_BUSTER=1
rm -rf /tmp/lmcache && export CACHE_BUSTER=2
ENV VLLM_DOCKER_BUILD_CONTEXT=1
# Needs git to be installed before this can run
#RUN curl -fsSL https://raw.githubusercontent.com/vllm-project/vllm/v0.20.0/tools/install_deepgemm.sh | bash
# Had to modify this
COPY ./install_deepgemm.sh /opt/install_deepgemm.sh
RUN chmod +x /opt/install_deepgemm.sh
RUN /opt/install_deepgemm.sh
# Copy over nemotron reasonong parser
COPY ./super_v3_reasoning_parser.py /opt/super_v3_reasoning_parser.py
#COPY ./super_v3_reasoning_parser.py /opt/super_v3_reasoning_parser.py
# Copy over deepseek tool call parser with MTP fixes
COPY deepseekv32_tool_parser.py /usr/local/lib/python3.12/dist-packages/vllm/tool_parsers/deepseekv32_tool_parser.py
#COPY deepseekv32_tool_parser.py /usr/local/lib/python3.12/dist-packages/vllm/tool_parsers/deepseekv32_tool_parser.py
#COPY deepseekv4_tool_parser.py /usr/local/lib/python3.12/dist-packages/vllm/tool_parsers/deepseekv4_tool_parser.py
# Copy over minimax tool call parser with kwargs fixes
COPY minimax_tool_parser.py /usr/local/lib/python3.12/dist-packages/vllm/tool_parsers/minimax_tool_parser.py
#COPY minimax_tool_parser.py /usr/local/lib/python3.12/dist-packages/vllm/tool_parsers/minimax_tool_parser.py
# Copy over minimax parsers with kwargs fixes
COPY minimax_tool_parser.py /usr/local/lib/python3.12/dist-packages/vllm/tool_parsers/minimax_tool_parser.py
COPY minimax_m2_parser.py /usr/local/lib/python3.12/dist-packages/vllm/parser/minimax_m2_parser.py
#COPY minimax_tool_parser.py /usr/local/lib/python3.12/dist-packages/vllm/tool_parsers/minimax_tool_parser.py
#COPY minimax_m2_parser.py /usr/local/lib/python3.12/dist-packages/vllm/parser/minimax_m2_parser.py
# Copy vLLM shim that intercepts --model to download custom weights from URLs
COPY vllm_shim_module.py /opt/vllm-shim/vllm_shim_module.py
# Shadow `python -m vllm.*` invocations via PYTHONPATH
# The shim masquerades as the vllm package so python -m vllm/entrypoints/openai/api_server
# hits our interceptor first, which downloads weights then execs the real vLLM
RUN mkdir -p /opt/vllm-shim/vllm/entrypoints/openai \
/opt/vllm-shim/vllm/entrypoints/cli && \
cp /opt/vllm-shim/vllm_shim_module.py /opt/vllm-shim/vllm/__main__.py && \
cp /opt/vllm-shim/vllm_shim_module.py /opt/vllm-shim/vllm/entrypoints/openai/api_server.py && \
cp /opt/vllm-shim/vllm_shim_module.py /opt/vllm-shim/vllm/entrypoints/cli/main.py && \
touch /opt/vllm-shim/vllm/__init__.py \
/opt/vllm-shim/vllm/entrypoints/__init__.py \
/opt/vllm-shim/vllm/entrypoints/openai/__init__.py \
/opt/vllm-shim/vllm/entrypoints/cli/__init__.py
ENV PYTHONPATH=/opt/vllm-shim
ENV PYTHONUNBUFFERED=1
# Fix DeepseekV4Config missing max_position_embeddings attr for RoPE standardization
#COPY deepseek_v4.py /usr/local/lib/python3.12/dist-packages/vllm/transformers_utils/configs/deepseek_v4.py

73
deepseek_v4.py Executable file
View File

@@ -0,0 +1,73 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from transformers import PretrainedConfig
class DeepseekV4Config(PretrainedConfig):
model_type = "deepseek_v4"
def __init__(
self,
vocab_size: int = 129024,
hidden_size: int = 7168,
intermediate_size: int = 18432,
moe_intermediate_size: int = 1408,
num_hidden_layers: int = 61,
num_attention_heads: int = 128,
num_key_value_heads: int = 128,
n_routed_experts: int = 256,
n_shared_experts: int = 1,
num_experts_per_tok: int = 8,
head_dim: int = 256,
q_lora_rank: int = 1536,
o_lora_rank: int = 1536,
qk_rope_head_dim: int = 64,
o_groups: int = 128,
rms_norm_eps: float = 1e-6,
rope_theta: float = 10000.0,
rope_scaling: dict | None = None,
max_position_embeddings: int = 163840,
hidden_act: str = "silu",
norm_topk_prob: bool = True,
compress_ratios: list | None = None,
compress_rope_theta: float = 10000.0,
num_hash_layers: int = 0,
hc_mult: float = 1.0,
hc_eps: float = 1e-6,
hc_sinkhorn_iters: int = 8,
swiglu_limit: int = 0,
index_topk: int = 0,
num_nextn_predict_layers: int = 0,
**kwargs,
):
self.vocab_size = vocab_size
self.hidden_size = hidden_size
self.intermediate_size = intermediate_size
self.moe_intermediate_size = moe_intermediate_size
self.num_hidden_layers = num_hidden_layers
self.num_attention_heads = num_attention_heads
self.num_key_value_heads = num_key_value_heads
self.n_routed_experts = n_routed_experts
self.n_shared_experts = n_shared_experts
self.num_experts_per_tok = num_experts_per_tok
self.head_dim = head_dim
self.q_lora_rank = q_lora_rank
self.o_lora_rank = o_lora_rank
self.qk_rope_head_dim = qk_rope_head_dim
self.o_groups = o_groups
self.rms_norm_eps = rms_norm_eps
self.rope_theta = rope_theta
self.rope_scaling = rope_scaling
self.max_position_embeddings = max_position_embeddings
self.hidden_act = hidden_act
self.norm_topk_prob = norm_topk_prob
self.compress_ratios = compress_ratios or [1] * num_hidden_layers
self.compress_rope_theta = compress_rope_theta
self.num_hash_layers = num_hash_layers
self.hc_mult = hc_mult
self.hc_eps = hc_eps
self.hc_sinkhorn_iters = hc_sinkhorn_iters
self.swiglu_limit = swiglu_limit
self.index_topk = index_topk
self.num_nextn_predict_layers = num_nextn_predict_layers
super().__init__(**kwargs)

33
deepseekv4_tool_parser.py Normal file
View File

@@ -0,0 +1,33 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import regex as re
from vllm.tool_parsers.deepseekv32_tool_parser import DeepSeekV32ToolParser
class DeepSeekV4ToolParser(DeepSeekV32ToolParser):
"""
DeepSeek V4 DSML tool parser.
V4 keeps the V3.2 DSML invoke/parameter grammar, but wraps tool calls in
``<DSMLtool_calls>`` instead of ``<DSMLfunction_calls>``.
"""
tool_call_start_token: str = "<DSMLtool_calls>"
tool_call_end_token: str = "</DSMLtool_calls>"
def __init__(self, tokenizer, tools=None):
super().__init__(tokenizer, tools)
# The V3.2 __init__ overwrites the class-level start/end tokens
# with instance attributes set to "function_calls". Re-override
# them to the V4 "tool_calls" tags so _extract_content(),
# _extract_invoke_regions(), etc. match the correct tags.
self.tool_call_start_token = "<DSMLtool_calls>"
self.tool_call_end_token = "</DSMLtool_calls>"
self.tool_calls_start_token = self.tool_call_start_token
# Recompile regexes with the V4 tool_calls tags instead of
# the V3.2 function_calls tags that the base class hardcodes.
self.tool_call_complete_regex = re.compile(
r"<DSMLtool_calls>(.*?)</DSMLtool_calls>", re.DOTALL
)

121
install_deepgemm.sh Normal file
View File

@@ -0,0 +1,121 @@
#!/bin/bash
# Script to build and/or install DeepGEMM from source
# Default: build and install immediately
# Optional: build wheels to a directory for later installation (useful in multi-stage builds)
set -e
# Default values
# Keep DEEPGEMM_GIT_REF in sync with cmake/external_projects/deepgemm.cmake
DEEPGEMM_GIT_REPO="https://github.com/deepseek-ai/DeepGEMM.git"
DEEPGEMM_GIT_REF="891d57b4db1071624b5c8fa0d1e51cb317fa709f"
WHEEL_DIR=""
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--ref)
if [[ -z "$2" || "$2" =~ ^- ]]; then
echo "Error: --ref requires an argument." >&2
exit 1
fi
DEEPGEMM_GIT_REF="$2"
shift 2
;;
--cuda-version)
if [[ -z "$2" || "$2" =~ ^- ]]; then
echo "Error: --cuda-version requires an argument." >&2
exit 1
fi
CUDA_VERSION="$2"
shift 2
;;
--wheel-dir)
if [[ -z "$2" || "$2" =~ ^- ]]; then
echo "Error: --wheel-dir requires a directory path." >&2
exit 1
fi
WHEEL_DIR="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " --ref REF Git reference to checkout (default: $DEEPGEMM_GIT_REF)"
echo " --cuda-version VER CUDA version (auto-detected if not provided)"
echo " --wheel-dir PATH If set, build wheel into PATH but do not install"
echo " -h, --help Show this help message"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
# Auto-detect CUDA version if not provided
if [ -z "$CUDA_VERSION" ]; then
if command -v nvcc >/dev/null 2>&1; then
CUDA_VERSION=$(nvcc --version | grep "release" | sed -n 's/.*release \([0-9]\+\.[0-9]\+\).*/\1/p')
echo "Auto-detected CUDA version: $CUDA_VERSION"
else
echo "Warning: Could not auto-detect CUDA version. Please specify with --cuda-version"
exit 1
fi
fi
# Extract major and minor version numbers
CUDA_MAJOR="${CUDA_VERSION%%.*}"
CUDA_MINOR="${CUDA_VERSION#"${CUDA_MAJOR}".}"
CUDA_MINOR="${CUDA_MINOR%%.*}"
echo "CUDA version: $CUDA_VERSION (major: $CUDA_MAJOR, minor: $CUDA_MINOR)"
# Check CUDA version requirement
if [ "$CUDA_MAJOR" -lt 12 ] || { [ "$CUDA_MAJOR" -eq 12 ] && [ "$CUDA_MINOR" -lt 8 ]; }; then
echo "Skipping DeepGEMM build/installation (requires CUDA 12.8+ but got ${CUDA_VERSION})"
exit 0
fi
echo "Preparing DeepGEMM build..."
echo "Repository: $DEEPGEMM_GIT_REPO"
echo "Reference: $DEEPGEMM_GIT_REF"
# Create a temporary directory for the build
INSTALL_DIR=$(mktemp -d)
trap 'rm -rf "$INSTALL_DIR"' EXIT
# Clone the repository
git clone --recursive --shallow-submodules "$DEEPGEMM_GIT_REPO" "$INSTALL_DIR/deepgemm"
pushd "$INSTALL_DIR/deepgemm"
# Checkout the specific reference
git checkout "$DEEPGEMM_GIT_REF"
# Clean previous build artifacts
# (Based on https://github.com/deepseek-ai/DeepGEMM/blob/main/install.sh)
rm -rf -- build dist *.egg-info 2>/dev/null || true
# Build wheel
echo "🏗️ Building DeepGEMM wheel..."
python3 setup.py bdist_wheel
# If --wheel-dir was specified, copy wheels there and exit
if [ -n "$WHEEL_DIR" ]; then
mkdir -p "$WHEEL_DIR"
cp dist/*.whl "$WHEEL_DIR"/
echo "✅ Wheel built and copied to $WHEEL_DIR"
popd
exit 0
fi
# Default behaviour: install built wheel
if command -v uv >/dev/null 2>&1; then
echo "Installing DeepGEMM wheel using uv..."
uv pip install --system dist/*.whl
else
echo "Installing DeepGEMM wheel using pip..."
python3 -m pip install dist/*.whl
fi
popd
echo "✅ DeepGEMM installation completed successfully"

View File

@@ -1,297 +0,0 @@
#!/usr/bin/env python3
"""
vLLM shim with custom weights download.
Intercepts `python -m vllm.entrypoints.openai.api_server` so that
if --model or the positional model arg (after "serve") points to a URL,
we download + extract it to a local cache dir, then replace it with
the local path before handing off to the real vLLM server.
Supported archive formats (detected from URL extension):
.tar, .tar.gz, .tgz, .tar.bz2, .tar.xz, .zip
"""
import os
import sys
import subprocess
import datetime
import shutil
import time
import urllib.parse
import urllib.request
# Where to cache downloaded+extracted weights
# Production stack mounts the PVC at /data — use a subdir so it persists across pod restarts
CACHE_DIR = os.environ.get("VLLM_WEIGHTS_CACHE", "/data/weights")
# The shim dir that shadows the vllm package — must be stripped from PYTHONPATH
# before exec'ing the real vLLM, otherwise we loop forever.
SHIM_DIR = "/opt/vllm-shim"
def log(msg: str):
"""Write to both stdout and the shim log file."""
log_path = os.environ.get("VLLM_SHIM_LOG", "/tmp/vllm-shim.log")
ts = datetime.datetime.now().isoformat()
line = f"[{ts}] {msg}"
print(line, flush=True)
try:
with open(log_path, "a") as f:
f.write(line + "\n")
except Exception:
pass
def is_url(value: str) -> bool:
return value.startswith("http://") or value.startswith("https://")
def detect_archive_type(url: str) -> str:
"""
Detect archive type from URL path extension.
Returns one of: 'tar', 'tar.gz', 'tar.bz2', 'tar.xz', 'zip', or '' (unknown).
"""
path = urllib.parse.urlparse(url).path
for ext in (".tar.gz", ".tar.bz2", ".tar.xz"):
if path.endswith(ext):
return ext.lstrip(".")
_, ext = os.path.splitext(path)
mapping = {
".tar": "tar",
".tgz": "tar.gz",
".zip": "zip",
}
return mapping.get(ext.lower(), "")
MAX_DOWNLOAD_RETRIES = int(os.environ.get("VLLM_SHIM_MAX_RETRIES", "5"))
RETRY_DELAY_SECONDS = 5
def download_file(url: str, dest: str):
"""Download url to dest with retries and a progress indicator."""
for attempt in range(1, MAX_DOWNLOAD_RETRIES + 1):
try:
log(f"Downloading {url} -> {dest} (attempt {attempt}/{MAX_DOWNLOAD_RETRIES})")
urllib.request.urlretrieve(url, dest, reporthook=_download_progress)
log(f"Download complete: {dest}")
return
except Exception as e:
log(f"Download attempt {attempt} failed: {e}")
if os.path.exists(dest):
os.remove(dest)
if attempt < MAX_DOWNLOAD_RETRIES:
wait = RETRY_DELAY_SECONDS * attempt
log(f"Retrying in {wait}s...")
time.sleep(wait)
else:
log(f"All {MAX_DOWNLOAD_RETRIES} download attempts failed")
raise
def _download_progress(block_num, block_size, total_size):
"""Simple download progress callback."""
if total_size <= 0:
return
downloaded = block_num * block_size
pct = min(downloaded * 100 // total_size, 100)
if pct % 10 == 0 and pct > 0:
mb_down = downloaded / (1024 * 1024)
mb_total = total_size / (1024 * 1024)
sys.stdout.write(f"\r {pct}% ({mb_down:.0f}/{mb_total:.0f} MB)")
sys.stdout.flush()
def extract_archive(archive_path: str, dest_dir: str, archive_type: str):
"""Extract archive to dest_dir based on archive_type."""
log(f"Extracting {archive_path} ({archive_type}) -> {dest_dir}")
if archive_type == "tar.gz" or archive_type == "tgz":
shutil.unpack_archive(archive_path, dest_dir, "gztar")
elif archive_type == "tar.bz2":
shutil.unpack_archive(archive_path, dest_dir, "bztar")
elif archive_type == "tar.xz":
subprocess.run(
["tar", "-xJf", archive_path, "-C", dest_dir],
check=True,
)
elif archive_type == "tar":
shutil.unpack_archive(archive_path, dest_dir, "tar")
elif archive_type == "zip":
shutil.unpack_archive(archive_path, dest_dir, "zip")
else:
raise ValueError(f"Unsupported archive type: {archive_type}")
log(f"Extraction complete: {dest_dir}")
def find_model_dir(extract_dir: str) -> str:
"""
After extraction, find the directory containing the actual model weights.
Walks the tree looking for .safetensors files and returns the directory
that contains one. This handles archives with extra parent dirs,
nested structures, or flat extractions.
"""
for root, dirs, files in os.walk(extract_dir):
if any(f.endswith(".safetensors") for f in files):
return root
log("WARNING: No .safetensors files found in extracted archive, falling back to single-dir heuristic")
entries = [e for e in os.listdir(extract_dir)
if not e.startswith(".") and e != "__MACOSX"]
if len(entries) == 1 and os.path.isdir(os.path.join(extract_dir, entries[0])):
return os.path.join(extract_dir, entries[0])
return extract_dir
def download_and_extract_model(url: str) -> str:
"""
Download a model from URL, extract it, and return the local path.
Uses a cache keyed by URL filename to avoid re-downloading.
"""
url_filename = os.path.basename(urllib.parse.urlparse(url).path)
cache_key = os.path.splitext(url_filename)[0]
local_dir = os.path.join(CACHE_DIR, cache_key)
if os.path.isdir(local_dir) and os.listdir(local_dir):
model_path = find_model_dir(local_dir)
log(f"Using cached weights: {model_path}")
return model_path
os.makedirs(local_dir, exist_ok=True)
archive_type = detect_archive_type(url)
if not archive_type:
raise ValueError(
f"Cannot determine archive type from URL: {url}\n"
f"Supported extensions: .tar, .tar.gz, .tgz, .tar.bz2, .tar.xz, .zip"
)
tmp_archive = os.path.join(CACHE_DIR, url_filename + ".tmp")
try:
download_file(url, tmp_archive)
extract_archive(tmp_archive, local_dir, archive_type)
finally:
if os.path.exists(tmp_archive):
os.remove(tmp_archive)
return find_model_dir(local_dir)
def parse_args(args):
"""
Parse argv, intercepting --model and positional model args.
Production stack invokes: python -m vllm.entrypoints.openai.api_server serve <model-url> ...
The model can appear as:
- --model <url>
- --model=<url>
- A positional arg after "serve" subcommand
If the value is a URL, download+extract and replace with local path.
Returns the modified argv list.
"""
result = []
i = 0
model_replaced = False
saw_serve = False
while i < len(args):
arg = args[i]
# --model=<value>
if arg.startswith("--model="):
value = arg.split("=", 1)[1]
if is_url(value):
local_path = download_and_extract_model(value)
result.append(f"--model={local_path}")
model_replaced = True
else:
result.append(arg)
i += 1
continue
# --model <value>
if arg == "--model":
result.append(arg)
i += 1
if i < len(args):
value = args[i]
if is_url(value):
local_path = download_and_extract_model(value)
result.append(local_path)
model_replaced = True
else:
result.append(value)
i += 1
continue
# "serve" subcommand — next positional is the model
if arg == "serve":
result.append(arg)
saw_serve = True
i += 1
# The next non-flag argument is the model
if i < len(args) and not args[i].startswith("-") and is_url(args[i]):
local_path = download_and_extract_model(args[i])
result.append(local_path)
model_replaced = True
i += 1
continue
# Positional model arg when there's no "serve" subcommand
# (first non-flag arg if no serve seen)
if not arg.startswith("-") and not saw_serve and not model_replaced:
if is_url(arg):
local_path = download_and_extract_model(arg)
result.append(local_path)
model_replaced = True
i += 1
continue
result.append(arg)
i += 1
if model_replaced:
log("Model URL was replaced with local path")
return result
def strip_shim_from_pythonpath():
"""
Remove the shim directory from PYTHONPATH so that when we exec the
real vLLM, Python doesn't find our shadow package again (infinite loop).
"""
pp = os.environ.get("PYTHONPATH", "")
parts = [p for p in pp.split(":") if p != SHIM_DIR]
new_pp = ":".join(parts)
if new_pp != pp:
os.environ["PYTHONPATH"] = new_pp
log(f"Stripped {SHIM_DIR} from PYTHONPATH (was: {pp!r}, now: {new_pp!r})")
def main():
args = sys.argv[1:]
# Determine which vllm module was actually invoked so we exec the real one
# (could be vllm.entrypoints.cli.main, vllm.entrypoints.openai.api_server, etc.)
invoked_module = __name__ # e.g. "vllm.entrypoints.cli.main" or "vllm.entrypoints.openai.api_server"
log("=" * 50)
log("vLLM Custom Weights Shim")
log(f" Invoked as: python -m {invoked_module} {' '.join(args)}")
log("=" * 50)
# Intercept --model / positional model if it's a URL
modified_args = parse_args(args)
# Strip our shim from PYTHONPATH so the real vLLM resolves correctly
strip_shim_from_pythonpath()
# Build the real vLLM command using the same module that was invoked
vllm_cmd = [sys.executable, "-m", invoked_module] + modified_args
log(f"Launching vLLM: {' '.join(vllm_cmd)}")
# Exec into vLLM — replace this process so signals flow through cleanly
os.execvp(vllm_cmd[0], vllm_cmd)
if __name__ == "__main__":
main()
# Also run if imported as a module (some invocation paths just import the file)
main()