mship-engine 0.7.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. modelship/__init__.py +0 -0
  2. modelship/deploy/__init__.py +0 -0
  3. modelship/deploy/actor_options.py +178 -0
  4. modelship/deploy/capabilities.py +84 -0
  5. modelship/deploy/config.py +131 -0
  6. modelship/deploy/effective_config.py +131 -0
  7. modelship/deploy/serve_utils.py +484 -0
  8. modelship/deploy/strategy.py +243 -0
  9. modelship/driver.py +377 -0
  10. modelship/infer/base_infer.py +473 -0
  11. modelship/infer/base_serving.py +21 -0
  12. modelship/infer/deploy_coordinator.py +204 -0
  13. modelship/infer/diffusers/diffusers_infer.py +171 -0
  14. modelship/infer/diffusers/openai/serving_image.py +230 -0
  15. modelship/infer/image_serving_common.py +83 -0
  16. modelship/infer/infer_config.py +649 -0
  17. modelship/infer/llama_server/llama_server_infer.py +885 -0
  18. modelship/infer/model_deployment.py +459 -0
  19. modelship/infer/model_resolver.py +271 -0
  20. modelship/infer/replica_coordinator.py +170 -0
  21. modelship/infer/sherpa_onnx/bundle.py +76 -0
  22. modelship/infer/sherpa_onnx/registry.py +138 -0
  23. modelship/infer/sherpa_onnx/sherpa_onnx_infer.py +154 -0
  24. modelship/infer/stable_diffusion_cpp/openai/serving_image.py +181 -0
  25. modelship/infer/stable_diffusion_cpp/stable_diffusion_cpp_infer.py +138 -0
  26. modelship/infer/vllm/capabilities.py +20 -0
  27. modelship/infer/vllm/engine_ops.py +646 -0
  28. modelship/infer/vllm/openai/serving_speech.py +6 -0
  29. modelship/infer/vllm/parsing/__init__.py +6 -0
  30. modelship/infer/vllm/parsing/detect.py +252 -0
  31. modelship/infer/vllm/vllm_infer.py +884 -0
  32. modelship/infer/whispercpp/whispercpp_infer.py +322 -0
  33. modelship/launcher.py +114 -0
  34. modelship/logging.py +243 -0
  35. modelship/metrics.py +409 -0
  36. modelship/openai/api.py +1151 -0
  37. modelship/openai/auth.py +186 -0
  38. modelship/openai/compaction_crypto.py +99 -0
  39. modelship/openai/mcp/__init__.py +5 -0
  40. modelship/openai/mcp/client.py +108 -0
  41. modelship/openai/mcp/egress.py +60 -0
  42. modelship/openai/mcp/loop.py +706 -0
  43. modelship/openai/mcp/spec.py +199 -0
  44. modelship/openai/protocol/__init__.py +163 -0
  45. modelship/openai/protocol/audio.py +170 -0
  46. modelship/openai/protocol/base.py +21 -0
  47. modelship/openai/protocol/chat.py +194 -0
  48. modelship/openai/protocol/embeddings.py +43 -0
  49. modelship/openai/protocol/error.py +67 -0
  50. modelship/openai/protocol/images.py +115 -0
  51. modelship/openai/protocol/responses/__init__.py +83 -0
  52. modelship/openai/protocol/responses/adapter.py +452 -0
  53. modelship/openai/protocol/responses/schemas.py +275 -0
  54. modelship/openai/protocol/responses/streaming.py +395 -0
  55. modelship/openai/protocol/usage.py +26 -0
  56. modelship/openai/state/__init__.py +22 -0
  57. modelship/openai/state/responses.py +322 -0
  58. modelship/openai/utils/__init__.py +4 -0
  59. modelship/openai/utils/audio.py +16 -0
  60. modelship/openai/utils/chat.py +293 -0
  61. modelship/openai/utils/responses.py +691 -0
  62. modelship/preflight/__init__.py +42 -0
  63. modelship/preflight/base.py +584 -0
  64. modelship/preflight/llama_cpp.py +536 -0
  65. modelship/preflight/stable_diffusion_cpp.py +42 -0
  66. modelship/preflight/vllm.py +822 -0
  67. modelship/state/__init__.py +169 -0
  68. modelship/state/base.py +97 -0
  69. modelship/state/memory.py +254 -0
  70. modelship/state/redis.py +154 -0
  71. modelship/utils/__init__.py +157 -0
  72. modelship/utils/accelerator.py +46 -0
  73. modelship/utils/audio.py +69 -0
  74. modelship/utils/cache.py +18 -0
  75. modelship/utils/cli.py +250 -0
  76. modelship/utils/ray_auth.py +25 -0
  77. modelship/utils/request_id.py +23 -0
  78. mship_engine-0.7.12.dist-info/METADATA +312 -0
  79. mship_engine-0.7.12.dist-info/RECORD +80 -0
  80. mship_engine-0.7.12.dist-info/WHEEL +4 -0
modelship/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,178 @@
1
+ """Ray Serve actor option construction for model deployments.
2
+
3
+ Centralises the GPU-allocation decisions for model deployments. Multi-slot
4
+ vLLM deploys always use a Ray Serve placement group (one whole-GPU bundle
5
+ per slot) that vLLM inherits via its ray distributed executor.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import platform
12
+
13
+ from modelship.deploy.capabilities import deployment_capability_resources
14
+ from modelship.infer.infer_config import ModelLoader, ModelshipModelConfig
15
+ from modelship.logging import get_logger
16
+ from modelship.utils.cache import resolve_cache_root
17
+
18
+ logger = get_logger("startup")
19
+
20
+ # Forwarded from the driver to each replica's runtime_env: logging vars, the gateway
21
+ # name (metrics.py stamps every metric with it), MSHIP_METRICS so --no-metrics on
22
+ # the driver also disables metrics in the replicas (else they'd default to on),
23
+ # MSHIP_PREFLIGHT so --no-preflight on the driver also disables it in the replicas
24
+ # (preflight runs inside each loader's actor __init__, not on the driver), and the
25
+ # /v1/responses state-store tuning read inside the gateway replica's own process
26
+ # (state.responses.ttl_seconds / state.memory._sweep_interval_s), not the driver's.
27
+ _PASSTHROUGH_ENV_VARS = (
28
+ "MSHIP_LOG_LEVEL",
29
+ "MSHIP_LOG_FORMAT",
30
+ "MSHIP_LOG_TARGET",
31
+ "MSHIP_GATEWAY_NAME",
32
+ "MSHIP_METRICS",
33
+ "MSHIP_PREFLIGHT",
34
+ "MSHIP_RESPONSES_TTL_S",
35
+ "MSHIP_STATE_SWEEP_INTERVAL_S",
36
+ )
37
+
38
+
39
+ def build_passthrough_env_vars() -> dict[str, str]:
40
+ """Driver→replica env vars (logging, gateway name, metrics) read off the
41
+ driver's environment. Shared by model and gateway deployments so both
42
+ replicas inherit the same logging/metrics config."""
43
+ return {var: os.environ[var] for var in _PASSTHROUGH_ENV_VARS if os.environ.get(var) is not None}
44
+
45
+
46
+ def build_cache_env_vars() -> dict[str, str]:
47
+ """Resolve HF / vLLM / FlashInfer cache dirs, all rooted at MSHIP_CACHE_DIR.
48
+
49
+ Also forwards HF_TOKEN/HF_HUB_OFFLINE when set on the driver, so an actor
50
+ downloading a gated/offline model has the same auth."""
51
+ base_cache = resolve_cache_root()
52
+ env_vars = {
53
+ "HF_HOME": os.environ.get("HF_HOME", f"{base_cache}/huggingface"),
54
+ "VLLM_CACHE_ROOT": os.environ.get("VLLM_CACHE_ROOT", f"{base_cache}/vllm"),
55
+ "FLASHINFER_CACHE_DIR": os.environ.get("FLASHINFER_CACHE_DIR", f"{base_cache}/flashinfer"),
56
+ # Triton JITs kernels at import for some archs
57
+ "TRITON_CACHE_DIR": os.environ.get("TRITON_CACHE_DIR", f"{base_cache}/triton"),
58
+ # vLLM's usage-stats thread writes usage_stats.json/do_not_track here
59
+ "VLLM_CONFIG_ROOT": os.environ.get("VLLM_CONFIG_ROOT", f"{base_cache}/vllm-config"),
60
+ # Default download dir for the whispercpp loader's pywhispercpp-managed
61
+ # built-in model names (a bare `model:` like `base.en`).
62
+ "MSHIP_WHISPERCPP_CACHE_DIR": os.environ.get("MSHIP_WHISPERCPP_CACHE_DIR", f"{base_cache}/whispercpp"),
63
+ }
64
+ for var in ("HF_TOKEN", "HF_HUB_OFFLINE"):
65
+ if os.environ.get(var) is not None:
66
+ env_vars[var] = os.environ[var]
67
+ return env_vars
68
+
69
+
70
+ def _world_size(config: ModelshipModelConfig) -> int:
71
+ if config.loader != ModelLoader.vllm:
72
+ return 1
73
+ tp = config.vllm_engine_kwargs.tensor_parallel_size
74
+ pp = config.vllm_engine_kwargs.pipeline_parallel_size
75
+ return tp * pp
76
+
77
+
78
+ def total_gpu_reservation(deploy_opts: dict) -> float:
79
+ """Sum the GPU units this deployment (actor + any PG bundles) will consume.
80
+
81
+ Used by the coordinator's resource tracker, which can't read the PG
82
+ bundle list as a single scalar.
83
+ """
84
+ return _total_reservation(deploy_opts, "GPU", "num_gpus")
85
+
86
+
87
+ def total_cpu_reservation(deploy_opts: dict) -> float:
88
+ """Sum the CPU units this deployment (actor + any PG bundles) will consume.
89
+
90
+ For multi-slot deploys the outer actor sits in bundle 0 and its CPU
91
+ request is satisfied from that bundle's reservation, so summing the
92
+ bundles gives the correct total — same shape as the GPU helper.
93
+ """
94
+ return _total_reservation(deploy_opts, "CPU", "num_cpus")
95
+
96
+
97
+ def _total_reservation(deploy_opts: dict, bundle_key: str, actor_key: str) -> float:
98
+ if "placement_group_bundles" in deploy_opts:
99
+ return float(sum(b.get(bundle_key, 0) for b in deploy_opts["placement_group_bundles"]))
100
+ return float(deploy_opts.get("ray_actor_options", {}).get(actor_key, 0) or 0)
101
+
102
+
103
+ def build_deployment_options(config: ModelshipModelConfig) -> dict:
104
+ """Return a kwargs dict for `Deployment.options(**...)`.
105
+
106
+ Always contains ``ray_actor_options``; for multi-slot vLLM deploys also
107
+ contains ``placement_group_bundles`` and ``placement_group_strategy`` so
108
+ Ray Serve allocates one whole-GPU bundle per slot and vLLM's ray executor
109
+ inherits the PG. When the model config sets ``max_ongoing_requests`` it is
110
+ forwarded as the per-replica Ray Serve concurrency cap.
111
+ """
112
+ env_vars = build_cache_env_vars()
113
+ env_vars.update(build_passthrough_env_vars())
114
+
115
+ runtime_env: dict = {"env_vars": env_vars}
116
+
117
+ capability_resources = deployment_capability_resources(config)
118
+
119
+ # sherpa_onnx never touches CUDA or CoreML (CPU only); ggml-backed loaders are
120
+ # CPU-only off Darwin, where forcing 0 would mislead Ray into co-scheduling
121
+ # another GPU actor onto the device Metal is actually using.
122
+ force_zero_gpu = config.loader == ModelLoader.sherpa_onnx or (
123
+ config.loader in (ModelLoader.stable_diffusion_cpp, ModelLoader.whispercpp) and platform.system() != "Darwin"
124
+ )
125
+ if force_zero_gpu:
126
+ if config.num_gpus > 0:
127
+ logger.warning(
128
+ "num_gpus=%s is ignored for model '%s': %s loader has no GPU backend here.",
129
+ config.num_gpus,
130
+ config.name,
131
+ config.loader.value,
132
+ )
133
+ opts: dict = {
134
+ "ray_actor_options": {
135
+ "num_gpus": 0,
136
+ "num_cpus": config.num_cpus,
137
+ "runtime_env": runtime_env,
138
+ "resources": capability_resources,
139
+ }
140
+ }
141
+ else:
142
+ world_size = _world_size(config)
143
+ if world_size == 1:
144
+ # Single slot: scalar Ray allocation. Fractional num_gpus (0 < n < 1)
145
+ # lets Ray pack other actors onto the same physical GPU.
146
+ opts = {
147
+ "ray_actor_options": {
148
+ "num_gpus": config.num_gpus,
149
+ "num_cpus": config.num_cpus,
150
+ "runtime_env": runtime_env,
151
+ "resources": capability_resources,
152
+ }
153
+ }
154
+ else:
155
+ # Multi-slot: one PG bundle per slot, STRICT_PACK keeps them on the
156
+ # same node (NVLink). Outer actor sits in bundle 0 with 0 GPU; vLLM's
157
+ # ray executor reuses the PG via get_current_placement_group() and
158
+ # pins each worker actor to its bundle. Each bundle requests a whole
159
+ # GPU, so Ray spreads across distinct physical GPUs. The capability
160
+ # resource is requested on every bundle (not the outer actor) since
161
+ # the bundles are what pin the deploy to a capable node.
162
+ bundles = [{"GPU": 1, "CPU": config.num_cpus, **capability_resources} for _ in range(world_size)]
163
+ opts = {
164
+ "ray_actor_options": {"num_gpus": 0, "num_cpus": config.num_cpus, "runtime_env": runtime_env},
165
+ "placement_group_bundles": bundles,
166
+ "placement_group_strategy": "STRICT_PACK",
167
+ }
168
+
169
+ # Per-model Ray Serve concurrency cap; only override the default when set.
170
+ # The reservation helpers read only the GPU/CPU keys, so this is inert there.
171
+ max_ongoing = config.max_ongoing_requests
172
+ if max_ongoing is None and config.loader == ModelLoader.llama_server:
173
+ parallel = config.llama_server_config.parallel if config.llama_server_config else 1
174
+ max_ongoing = parallel
175
+
176
+ if max_ongoing is not None:
177
+ opts["max_ongoing_requests"] = max_ongoing
178
+ return opts
@@ -0,0 +1,84 @@
1
+ """Node/deployment capability resources: `mship_<loader>` alongside Ray's native
2
+ `GPU`, so a deploy only schedules onto a node with that loader installed.
3
+
4
+ Must stay ray-free at import time: modelship/launcher.py imports LOADER_MODULES
5
+ from here before resolve_ray_auth_env runs, so the ModelshipModelConfig annotation
6
+ below is deferred behind `if TYPE_CHECKING` rather than imported for real.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import json
13
+ import os
14
+ import re
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from modelship.infer.infer_config import ModelshipModelConfig
19
+
20
+ RESOURCE_PREFIX = "mship_"
21
+
22
+ # loader name -> the module find_spec() probes for it. llama_server is handled
23
+ # separately below (a subprocess binary, not an importable module).
24
+ LOADER_MODULES = {
25
+ "vllm": "vllm",
26
+ "diffusers": "diffusers",
27
+ "stable_diffusion_cpp": "stable_diffusion_cpp",
28
+ "whispercpp": "pywhispercpp",
29
+ "sherpa_onnx": "sherpa_onnx",
30
+ }
31
+
32
+ _LLAMA_SERVER_LOADER = "llama_server"
33
+
34
+ # Every loader that gets a capability resource, including llama_server (probed via a
35
+ # binary check below, not find_spec like the LOADER_MODULES entries).
36
+ ALL_CAPABILITY_LOADERS = frozenset({*LOADER_MODULES, _LLAMA_SERVER_LOADER})
37
+
38
+ # Matches a wrapper script's `exec <target> ...` line, quoted or not (Dockerfile's
39
+ # llama-server.sh emits it unquoted; launcher._write_wrapper quotes it).
40
+ _WRAPPER_EXEC_RE = re.compile(r'exec\s+"?([^"\s]+)"?')
41
+
42
+
43
+ def node_capability_resources() -> dict[str, float]:
44
+ """{"mship_vllm": 1, ...} for every loader this node can run. MSHIP_NODE_CAPABILITIES
45
+ (a JSON object) overrides the probe wholesale."""
46
+ override = os.environ.get("MSHIP_NODE_CAPABILITIES")
47
+ if override:
48
+ return {str(name): float(qty) for name, qty in json.loads(override).items()}
49
+
50
+ resources: dict[str, float] = {}
51
+ for loader, module in LOADER_MODULES.items():
52
+ if importlib.util.find_spec(module) is not None:
53
+ resources[f"{RESOURCE_PREFIX}{loader}"] = 1
54
+ if _llama_server_available():
55
+ resources[f"{RESOURCE_PREFIX}{_LLAMA_SERVER_LOADER}"] = 1
56
+ return resources
57
+
58
+
59
+ def deployment_capability_resources(config: ModelshipModelConfig) -> dict[str, float]:
60
+ """{"mship_vllm": 0.001} for the config's loader."""
61
+ return {f"{RESOURCE_PREFIX}{config.loader!s}": 0.001}
62
+
63
+
64
+ def _llama_server_available() -> bool:
65
+ """`thin` bakes the wrapper script unconditionally but ships no binary behind
66
+ it, so a bare existence check on MSHIP_LLAMA_SERVER_BIN can't tell `thin`
67
+ apart from `cpu`/`cuda`/`metal` — resolve the wrapper's own exec target."""
68
+ bin_path = os.environ.get("MSHIP_LLAMA_SERVER_BIN")
69
+ if not bin_path or not os.path.isfile(bin_path):
70
+ return False
71
+ target = _wrapper_exec_target(bin_path)
72
+ if target is None:
73
+ return True # not a recognized wrapper — the existence check above suffices
74
+ return os.path.isfile(target)
75
+
76
+
77
+ def _wrapper_exec_target(path: str) -> str | None:
78
+ try:
79
+ with open(path) as f:
80
+ content = f.read()
81
+ except OSError:
82
+ return None
83
+ match = _WRAPPER_EXEC_RE.search(content)
84
+ return match.group(1) if match else None
@@ -0,0 +1,131 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+ from pydantic_yaml import parse_yaml_raw_as
6
+
7
+ from modelship.infer.infer_config import ModelLoader, ModelshipConfig
8
+ from modelship.infer.model_resolver import check_model_source
9
+ from modelship.logging import get_logger
10
+ from modelship.utils import is_pathy
11
+
12
+ logger = get_logger("startup")
13
+
14
+
15
+ def default_config_path(config_dir: Path | None = None) -> Path:
16
+ """The default config/models.yaml path used absent an explicit --config."""
17
+ config_dir = config_dir or Path(__file__).resolve().parent.parent.parent / "config"
18
+ return config_dir / "models.yaml"
19
+
20
+
21
+ def resolve_config_path(arg_path: str | None, config_dir: Path | None = None) -> str:
22
+ """Resolve the models.yaml to deploy.
23
+
24
+ Precedence:
25
+ 1. An explicit ``--config`` path always wins (most specific signal); it must exist.
26
+ 2. Otherwise the default ``config/models.yaml`` must exist.
27
+ """
28
+ if arg_path:
29
+ if not os.path.exists(arg_path):
30
+ raise FileNotFoundError(f"--config {arg_path} not found.")
31
+ return arg_path
32
+
33
+ default = default_config_path(config_dir)
34
+ if default.exists():
35
+ return str(default)
36
+
37
+ raise FileNotFoundError(f"{default} not found. Copy an example config from config/examples/ to config/models.yaml.")
38
+
39
+
40
+ def load_yaml_config(arg_path: str | None) -> ModelshipConfig:
41
+ with open(resolve_config_path(arg_path)) as f:
42
+ return parse_yaml_raw_as(ModelshipConfig, f)
43
+
44
+
45
+ def load_raw_models(arg_path: str | None) -> list[dict]:
46
+ """Read the user's models.yaml as raw, pre-validation dicts.
47
+
48
+ The effective-config store keeps raw dicts (not validated configs, which don't
49
+ round-trip through num_gpus/tp normalization), so the deploy path merges at the
50
+ raw-dict level; ``merge()`` validates this input before folding it in, and the
51
+ merged result is validated again before deploy."""
52
+ with open(resolve_config_path(arg_path)) as f:
53
+ data = yaml.safe_load(f) or {}
54
+ if not isinstance(data, dict):
55
+ raise ValueError("models.yaml: top-level document must be a mapping with a 'models' key.")
56
+ models = data.get("models", [])
57
+ if not isinstance(models, list):
58
+ raise ValueError("models.yaml: 'models' must be a list.")
59
+ return models
60
+
61
+
62
+ def _is_whispercpp_builtin_ref(model: str) -> bool:
63
+ """A bare pywhispercpp built-in name (e.g. `base.en`) — no repo or path
64
+ separator. Validated in the actor; the driver may lack pywhispercpp."""
65
+ return "/" not in model and not os.path.exists(model)
66
+
67
+
68
+ def _resolve_sherpa_onnx_source(cfg) -> None:
69
+ """Not an HF repo, so no `check_model_source`. A local-directory `model:` is
70
+ validated here at driver preflight; a bare registry name has nothing to pin
71
+ since the tarball fetch happens in the actor."""
72
+ from modelship.infer.sherpa_onnx.bundle import validate_bundle
73
+ from modelship.infer.sherpa_onnx.registry import REGISTRY
74
+
75
+ model = cfg.model
76
+ assert model is not None # validator guarantees this for built-in loaders
77
+ if is_pathy(model):
78
+ path = os.path.expanduser(model)
79
+ name = os.path.basename(path.rstrip("/"))
80
+ entry = REGISTRY[name] # config validation already guarantees this key exists
81
+ logger.info("Checking sherpa_onnx bundle for '%s': %s", cfg.name, path)
82
+ validate_bundle(path, entry)
83
+ logger.info("Checked '%s' (local bundle, no download)", cfg.name)
84
+ else:
85
+ logger.info(
86
+ "Skipping source check for '%s': sherpa_onnx registry model %r is fetched by the actor", cfg.name, model
87
+ )
88
+
89
+
90
+ def resolve_all_model_sources(yml_conf: ModelshipConfig) -> None:
91
+ """Pre-flight: check every built-in-loader model's source, without
92
+ downloading any weight bytes.
93
+
94
+ Populates `_pinned_source` (and, for llama_server, the mmproj pin) on each
95
+ config in place; actual download happens per-replica in
96
+ `BaseInfer.ensure_downloaded`. Raises on the first failure (auth,
97
+ missing repo, missing file, glob-no-match) so the operator sees it before
98
+ any Ray actor spins up.
99
+
100
+ Note: HF_HOME / VLLM_CACHE_ROOT / FLASHINFER_CACHE_DIR are set at module
101
+ load time in mship_deploy.py — `huggingface_hub.HF_HOME` is latched at
102
+ import, so setting them later doesn't take effect.
103
+ """
104
+ for cfg in yml_conf.models:
105
+ if cfg.loader == ModelLoader.whispercpp and cfg.model and _is_whispercpp_builtin_ref(cfg.model):
106
+ # pywhispercpp resolves/downloads its own built-in models; nothing to pin here.
107
+ logger.info("Skipping source check for '%s': pywhispercpp built-in model %r", cfg.name, cfg.model)
108
+ continue
109
+ if cfg.loader == ModelLoader.sherpa_onnx:
110
+ _resolve_sherpa_onnx_source(cfg)
111
+ continue
112
+ assert cfg.model is not None # validator guarantees this for built-in loaders
113
+ trust_remote_code = bool(cfg.vllm_engine_kwargs and cfg.vllm_engine_kwargs.trust_remote_code)
114
+ logger.info("Checking model source for '%s': %s", cfg.name, cfg.model)
115
+ cfg._pinned_source = check_model_source(cfg.model, trust_remote_code=trust_remote_code)
116
+ logger.info("Checked '%s' (revision=%s)", cfg.name, cfg._pinned_source.revision or "local")
117
+
118
+ if cfg.loader == ModelLoader.llama_server and cfg.llama_server_config and cfg.llama_server_config.mmproj:
119
+ logger.info("Checking mmproj source for '%s': %s", cfg.name, cfg.llama_server_config.mmproj)
120
+ cfg.llama_server_config._pinned_mmproj = check_model_source(
121
+ cfg.llama_server_config.mmproj, trust_remote_code=trust_remote_code
122
+ )
123
+
124
+ # GGUF is not supported on the vllm loader (vLLM 0.24 dropped in-tree
125
+ # GGUF). Reject early using the listed filename, before any download.
126
+ if cfg.loader == ModelLoader.vllm and cfg._pinned_source.resolves_to_gguf:
127
+ raise ValueError(
128
+ f"Model '{cfg.name}' resolves to a GGUF file, which the vllm loader does not support "
129
+ f"(vLLM 0.24 dropped in-tree GGUF). Use `loader: llama_server` for GGUF models, or point "
130
+ f"the vllm loader at a non-GGUF checkpoint (safetensors, or an AWQ/GPTQ/FP8 quant)."
131
+ )
@@ -0,0 +1,131 @@
1
+ """Per-gateway *effective config* — the durable desired-state for deploys.
2
+
3
+ Every ``mship_deploy`` invocation, whatever its mode, folds the user's input into
4
+ the gateway's effective set (additive = union; reconcile = replace), then
5
+ the deploy ALWAYS reconciles the live cluster to that effective set. Self-heal is
6
+ then just "re-run the deploy": it reads the persisted effective set and reconciles
7
+ onto an empty cluster, restoring the TRUE live set after the cluster dies — not
8
+ just whatever the last user input happened to contain.
9
+
10
+ The store holds **raw, user-equivalent model dicts**, NOT serialized validated
11
+ configs: ``ModelshipModelConfig``'s ``num_gpus``/``tensor_parallel_size``
12
+ normalization is not idempotent, so a dumped validated config fails (or silently
13
+ mutates its fingerprint) on reload. Raw input dicts reload exactly as written.
14
+
15
+ This is the deploy-domain layer over the generic ``modelship.state`` store.
16
+ """
17
+
18
+ from typing import Literal
19
+
20
+ from modelship.infer.infer_config import ModelshipConfig, ModelshipModelConfig
21
+ from modelship.logging import get_logger
22
+ from modelship.state import StateStore
23
+
24
+ logger = get_logger("startup")
25
+
26
+ DeployMode = Literal["additive", "reconcile"]
27
+
28
+ # State-store namespace; one key per gateway: "effective/<gateway-name>".
29
+ _NAMESPACE = "effective"
30
+
31
+
32
+ def resolve_mode(*, reconcile: bool) -> DeployMode:
33
+ """Map the CLI flags to the effective-config merge verb."""
34
+ return "reconcile" if reconcile else "additive"
35
+
36
+
37
+ def _deployment_name(raw: dict, gateway_name: str) -> str:
38
+ """Deployment name (name + fingerprint) for a raw model dict — the identity
39
+ key for additive de-dup and fatal-failure eviction. Validates the dict
40
+ (running normalization) so two raw dicts that normalize identically map to the
41
+ same deployment."""
42
+ return ModelshipModelConfig.model_validate(raw).deployment_name(gateway_name)
43
+
44
+
45
+ def _model_name(raw: dict) -> str:
46
+ """Human-facing model name for a raw model dict."""
47
+ return ModelshipModelConfig.model_validate(raw).name
48
+
49
+
50
+ def _identity(raw: dict, gateway_name: str) -> tuple[str, str]:
51
+ """(deployment_name, model_name) for a raw model dict from one validation pass."""
52
+ cfg = ModelshipModelConfig.model_validate(raw)
53
+ return cfg.deployment_name(gateway_name), cfg.name
54
+
55
+
56
+ def merge(
57
+ effective_raw: list[dict],
58
+ input_raw: list[dict],
59
+ gateway_name: str,
60
+ mode: DeployMode,
61
+ ) -> list[dict]:
62
+ """Fold the user's input into the effective raw model set under *mode*.
63
+
64
+ - additive: replace-by-name — identical config (same deployment_name) is an
65
+ idempotent skip; a different config sharing a model name replaces the
66
+ existing entry for that name rather than joining it.
67
+ - reconcile: input replaces the effective set entirely.
68
+
69
+ Validates *input_raw* alone (not the merged result) via ModelshipConfig, so a
70
+ model name reused with a different config in this file is rejected before it
71
+ ever reaches the persisted effective set; pre-existing effective state from
72
+ before this rule existed is left alone.
73
+ """
74
+ to_config(input_raw)
75
+ if mode == "reconcile":
76
+ return list(input_raw)
77
+
78
+ # dep_name -> raw dict, and model_name -> its current dep_name, both built in
79
+ # one validation pass per dict so lookups below are O(1) instead of rescanning
80
+ # (and re-validating) the whole accumulated set per input entry.
81
+ merged: dict[str, dict] = {}
82
+ dep_name_by_model_name: dict[str, str] = {}
83
+ for m in effective_raw:
84
+ dep_name, model_name = _identity(m, gateway_name)
85
+ merged[dep_name] = m
86
+ dep_name_by_model_name[model_name] = dep_name
87
+
88
+ for d in input_raw:
89
+ dep_name, model_name = _identity(d, gateway_name)
90
+ if dep_name in merged:
91
+ continue
92
+ prior_dep_name = dep_name_by_model_name.get(model_name)
93
+ if prior_dep_name is not None:
94
+ del merged[prior_dep_name]
95
+ merged[dep_name] = d
96
+ dep_name_by_model_name[model_name] = dep_name
97
+
98
+ return list(merged.values())
99
+
100
+
101
+ def deployment_names(raw_models: list[dict], gateway_name: str) -> set[str]:
102
+ """The deployment-name set for raw model dicts — the identity set of what's
103
+ under this gateway's effective management. Passed to the deploy plan so a
104
+ reconcile only removes deployments that WERE effective-managed (never legacy /
105
+ un-tracked deployments or another gateway's apps). Relies on the effective
106
+ config being per-gateway and the gateway being folded into each fingerprint."""
107
+ return {_deployment_name(d, gateway_name) for d in raw_models}
108
+
109
+
110
+ def to_config(raw_models: list[dict]) -> ModelshipConfig:
111
+ """Validate raw model dicts into a ModelshipConfig for the deploy path."""
112
+ return ModelshipConfig.model_validate({"models": raw_models})
113
+
114
+
115
+ def read_effective(store: StateStore, gateway_name: str) -> list[dict]:
116
+ """Return the persisted effective raw model set for *gateway_name* (empty if
117
+ none yet)."""
118
+ data = store.get(f"{_NAMESPACE}/{gateway_name}")
119
+ if not isinstance(data, dict):
120
+ return []
121
+ models = data.get("models", [])
122
+ if not isinstance(models, list):
123
+ logger.warning("Effective config for gateway %r has non-list 'models'; treating as empty.", gateway_name)
124
+ return []
125
+ return models
126
+
127
+
128
+ def write_effective(store: StateStore, gateway_name: str, raw_models: list[dict]) -> None:
129
+ """Persist the effective raw model set for *gateway_name*."""
130
+ store.set(f"{_NAMESPACE}/{gateway_name}", {"models": raw_models})
131
+ logger.info("Effective config for gateway %r now has %d model(s).", gateway_name, len(raw_models))