mship 0.7.3__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 (75) hide show
  1. modelship/__init__.py +0 -0
  2. modelship/deploy/__init__.py +0 -0
  3. modelship/deploy/actor_options.py +186 -0
  4. modelship/deploy/config.py +109 -0
  5. modelship/deploy/effective_config.py +101 -0
  6. modelship/deploy/serve_utils.py +468 -0
  7. modelship/deploy/strategy.py +244 -0
  8. modelship/driver.py +366 -0
  9. modelship/infer/base_infer.py +467 -0
  10. modelship/infer/base_serving.py +21 -0
  11. modelship/infer/custom/custom_infer.py +89 -0
  12. modelship/infer/custom/openai/serving_speech.py +56 -0
  13. modelship/infer/custom/openai/serving_transcription.py +83 -0
  14. modelship/infer/deploy_coordinator.py +204 -0
  15. modelship/infer/diffusers/diffusers_infer.py +171 -0
  16. modelship/infer/diffusers/openai/serving_image.py +230 -0
  17. modelship/infer/image_serving_common.py +83 -0
  18. modelship/infer/infer_config.py +621 -0
  19. modelship/infer/llama_server/llama_server_infer.py +854 -0
  20. modelship/infer/model_deployment.py +454 -0
  21. modelship/infer/model_resolver.py +249 -0
  22. modelship/infer/replica_coordinator.py +158 -0
  23. modelship/infer/stable_diffusion_cpp/openai/serving_image.py +181 -0
  24. modelship/infer/stable_diffusion_cpp/stable_diffusion_cpp_infer.py +138 -0
  25. modelship/infer/vllm/capabilities.py +20 -0
  26. modelship/infer/vllm/engine_ops.py +632 -0
  27. modelship/infer/vllm/openai/serving_speech.py +6 -0
  28. modelship/infer/vllm/parsing/__init__.py +6 -0
  29. modelship/infer/vllm/parsing/detect.py +252 -0
  30. modelship/infer/vllm/vllm_infer.py +863 -0
  31. modelship/launcher.py +163 -0
  32. modelship/logging.py +229 -0
  33. modelship/metrics.py +409 -0
  34. modelship/openai/api.py +1074 -0
  35. modelship/openai/auth.py +186 -0
  36. modelship/openai/compaction_crypto.py +99 -0
  37. modelship/openai/protocol/__init__.py +169 -0
  38. modelship/openai/protocol/audio.py +170 -0
  39. modelship/openai/protocol/base.py +21 -0
  40. modelship/openai/protocol/chat.py +194 -0
  41. modelship/openai/protocol/embeddings.py +43 -0
  42. modelship/openai/protocol/error.py +67 -0
  43. modelship/openai/protocol/images.py +115 -0
  44. modelship/openai/protocol/raw.py +57 -0
  45. modelship/openai/protocol/responses/__init__.py +73 -0
  46. modelship/openai/protocol/responses/adapter.py +361 -0
  47. modelship/openai/protocol/responses/schemas.py +227 -0
  48. modelship/openai/protocol/responses/streaming.py +393 -0
  49. modelship/openai/protocol/usage.py +26 -0
  50. modelship/openai/state/__init__.py +22 -0
  51. modelship/openai/state/responses.py +119 -0
  52. modelship/openai/utils/__init__.py +4 -0
  53. modelship/openai/utils/chat.py +293 -0
  54. modelship/openai/utils/responses.py +333 -0
  55. modelship/plugins/base_plugin.py +147 -0
  56. modelship/preflight/__init__.py +38 -0
  57. modelship/preflight/base.py +501 -0
  58. modelship/preflight/llama_cpp.py +531 -0
  59. modelship/preflight/stable_diffusion_cpp.py +42 -0
  60. modelship/preflight/vllm.py +896 -0
  61. modelship/state/__init__.py +161 -0
  62. modelship/state/base.py +83 -0
  63. modelship/state/memory.py +208 -0
  64. modelship/state/redis.py +129 -0
  65. modelship/utils/__init__.py +113 -0
  66. modelship/utils/accelerator.py +29 -0
  67. modelship/utils/audio.py +69 -0
  68. modelship/utils/cache.py +18 -0
  69. modelship/utils/cli.py +250 -0
  70. modelship/utils/ray_auth.py +25 -0
  71. modelship/utils/request_id.py +23 -0
  72. mship-0.7.3.dist-info/METADATA +319 -0
  73. mship-0.7.3.dist-info/RECORD +75 -0
  74. mship-0.7.3.dist-info/WHEEL +4 -0
  75. mship-0.7.3.dist-info/entry_points.txt +3 -0
modelship/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,186 @@
1
+ """Ray Serve actor option construction for model deployments.
2
+
3
+ Centralises the GPU-allocation decisions and the plugin-wheel runtime_env
4
+ injection for custom-loader models. Multi-slot vLLM deploys always use a
5
+ Ray Serve placement group (one whole-GPU bundle per slot) that vLLM
6
+ inherits via its ray distributed executor.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import platform
13
+ from pathlib import Path
14
+
15
+ from modelship.infer.infer_config import ModelLoader, ModelshipModelConfig
16
+ from modelship.logging import get_logger
17
+ from modelship.utils.cache import resolve_cache_root
18
+
19
+ logger = get_logger("startup")
20
+
21
+ # Forwarded from the driver to each replica's runtime_env: logging vars, the gateway
22
+ # name (metrics.py stamps every metric with it), MSHIP_METRICS so --no-metrics on
23
+ # the driver also disables metrics in the replicas (else they'd default to on),
24
+ # MSHIP_PREFLIGHT so --no-preflight on the driver also disables it in the replicas
25
+ # (preflight runs inside each loader's actor __init__, not on the driver), and the
26
+ # /v1/responses state-store tuning read inside the gateway replica's own process
27
+ # (state.responses.ttl_seconds / state.memory._sweep_interval_s), not the driver's.
28
+ _PASSTHROUGH_ENV_VARS = (
29
+ "MSHIP_LOG_LEVEL",
30
+ "MSHIP_LOG_FORMAT",
31
+ "MSHIP_LOG_TARGET",
32
+ "MSHIP_GATEWAY_NAME",
33
+ "MSHIP_METRICS",
34
+ "MSHIP_PREFLIGHT",
35
+ "MSHIP_RESPONSES_TTL_S",
36
+ "MSHIP_STATE_SWEEP_INTERVAL_S",
37
+ )
38
+
39
+
40
+ def build_passthrough_env_vars() -> dict[str, str]:
41
+ """Driver→replica env vars (logging, gateway name, metrics) read off the
42
+ driver's environment. Shared by model and gateway deployments so both
43
+ replicas inherit the same logging/metrics config."""
44
+ return {var: os.environ[var] for var in _PASSTHROUGH_ENV_VARS if os.environ.get(var) is not None}
45
+
46
+
47
+ def build_cache_env_vars() -> dict[str, str]:
48
+ """Resolve HF / vLLM / FlashInfer cache dirs, all rooted at MSHIP_CACHE_DIR.
49
+
50
+ Also forwards HF_TOKEN/HF_HUB_OFFLINE when set on the driver, so an actor
51
+ downloading a gated/offline model has the same auth."""
52
+ base_cache = resolve_cache_root()
53
+ env_vars = {
54
+ "HF_HOME": os.environ.get("HF_HOME", f"{base_cache}/huggingface"),
55
+ "VLLM_CACHE_ROOT": os.environ.get("VLLM_CACHE_ROOT", f"{base_cache}/vllm"),
56
+ "FLASHINFER_CACHE_DIR": os.environ.get("FLASHINFER_CACHE_DIR", f"{base_cache}/flashinfer"),
57
+ # Triton JITs kernels at import for some archs
58
+ "TRITON_CACHE_DIR": os.environ.get("TRITON_CACHE_DIR", f"{base_cache}/triton"),
59
+ # vLLM's usage-stats thread writes usage_stats.json/do_not_track here
60
+ "VLLM_CONFIG_ROOT": os.environ.get("VLLM_CONFIG_ROOT", f"{base_cache}/vllm-config"),
61
+ }
62
+ for var in ("HF_TOKEN", "HF_HUB_OFFLINE"):
63
+ if os.environ.get(var) is not None:
64
+ env_vars[var] = os.environ[var]
65
+ return env_vars
66
+
67
+
68
+ def _plugin_wheel_dir() -> Path:
69
+ return Path(os.environ.get("MSHIP_PLUGIN_WHEEL_DIR", ".build/plugin-wheels"))
70
+
71
+
72
+ def resolve_plugin_wheel(plugin: str) -> Path:
73
+ wheel_dir = _plugin_wheel_dir()
74
+ normalized_name = plugin.replace("-", "_")
75
+ wheels = sorted(wheel_dir.glob(f"{normalized_name}-*.whl"))
76
+ if not wheels:
77
+ raise RuntimeError(
78
+ f"No wheel found for plugin '{plugin}' (normalized: '{normalized_name}') in {wheel_dir}. "
79
+ f"Build wheels with `make plugin-wheels` (or rebuild the Docker image), "
80
+ f"or set MSHIP_PLUGIN_WHEEL_DIR to the directory containing them."
81
+ )
82
+ # Absolute path required: Ray workers run with a different cwd
83
+ # (/tmp/ray/session_*/runtime_resources/.../exec_cwd), so a relative wheel
84
+ # path in runtime_env.pip would fail to resolve on the worker.
85
+ return wheels[-1].resolve()
86
+
87
+
88
+ def _world_size(config: ModelshipModelConfig) -> int:
89
+ if config.loader != ModelLoader.vllm:
90
+ return 1
91
+ tp = config.vllm_engine_kwargs.tensor_parallel_size
92
+ pp = config.vllm_engine_kwargs.pipeline_parallel_size
93
+ return tp * pp
94
+
95
+
96
+ def total_gpu_reservation(deploy_opts: dict) -> float:
97
+ """Sum the GPU units this deployment (actor + any PG bundles) will consume.
98
+
99
+ Used by the coordinator's resource tracker, which can't read the PG
100
+ bundle list as a single scalar.
101
+ """
102
+ return _total_reservation(deploy_opts, "GPU", "num_gpus")
103
+
104
+
105
+ def total_cpu_reservation(deploy_opts: dict) -> float:
106
+ """Sum the CPU units this deployment (actor + any PG bundles) will consume.
107
+
108
+ For multi-slot deploys the outer actor sits in bundle 0 and its CPU
109
+ request is satisfied from that bundle's reservation, so summing the
110
+ bundles gives the correct total — same shape as the GPU helper.
111
+ """
112
+ return _total_reservation(deploy_opts, "CPU", "num_cpus")
113
+
114
+
115
+ def _total_reservation(deploy_opts: dict, bundle_key: str, actor_key: str) -> float:
116
+ if "placement_group_bundles" in deploy_opts:
117
+ return float(sum(b.get(bundle_key, 0) for b in deploy_opts["placement_group_bundles"]))
118
+ return float(deploy_opts.get("ray_actor_options", {}).get(actor_key, 0) or 0)
119
+
120
+
121
+ def build_deployment_options(config: ModelshipModelConfig, plugin_wheel: Path | None = None) -> dict:
122
+ """Return a kwargs dict for `Deployment.options(**...)`.
123
+
124
+ Always contains ``ray_actor_options``; for multi-slot vLLM deploys also
125
+ contains ``placement_group_bundles`` and ``placement_group_strategy`` so
126
+ Ray Serve allocates one whole-GPU bundle per slot and vLLM's ray executor
127
+ inherits the PG. When the model config sets ``max_ongoing_requests`` it is
128
+ forwarded as the per-replica Ray Serve concurrency cap.
129
+ """
130
+ env_vars = build_cache_env_vars()
131
+ env_vars.update(build_passthrough_env_vars())
132
+
133
+ runtime_env: dict = {"env_vars": env_vars}
134
+ if plugin_wheel is not None:
135
+ # Ship the plugin to the Ray worker via runtime_env. Ray content-hashes
136
+ # and caches the resulting per-job venv, so repeat deploys of the same
137
+ # wheel reuse the install.
138
+ runtime_env["pip"] = [str(plugin_wheel)]
139
+
140
+ if config.loader == ModelLoader.stable_diffusion_cpp and platform.system() != "Darwin":
141
+ # Off Darwin the loader's ggml backend genuinely is CPU-only. On Darwin,
142
+ # ggml's runtime device registry picks up Metal automatically — forcing
143
+ # 0 here wouldn't stop the actor from using the GPU, it would just make
144
+ # Ray believe it isn't, letting it co-schedule another GPU actor onto it.
145
+ if config.num_gpus > 0:
146
+ logger.warning(
147
+ "num_gpus=%s is ignored for model '%s': stable_diffusion_cpp loader only supports GPU on Metal.",
148
+ config.num_gpus,
149
+ config.name,
150
+ )
151
+ opts: dict = {"ray_actor_options": {"num_gpus": 0, "num_cpus": config.num_cpus, "runtime_env": runtime_env}}
152
+ else:
153
+ world_size = _world_size(config)
154
+ if world_size == 1:
155
+ # Single slot: scalar Ray allocation. Fractional num_gpus (0 < n < 1)
156
+ # lets Ray pack other actors onto the same physical GPU.
157
+ opts = {
158
+ "ray_actor_options": {
159
+ "num_gpus": config.num_gpus,
160
+ "num_cpus": config.num_cpus,
161
+ "runtime_env": runtime_env,
162
+ }
163
+ }
164
+ else:
165
+ # Multi-slot: one PG bundle per slot, STRICT_PACK keeps them on the
166
+ # same node (NVLink). Outer actor sits in bundle 0 with 0 GPU; vLLM's
167
+ # ray executor reuses the PG via get_current_placement_group() and
168
+ # pins each worker actor to its bundle. Each bundle requests a whole
169
+ # GPU, so Ray spreads across distinct physical GPUs.
170
+ bundles = [{"GPU": 1, "CPU": config.num_cpus} for _ in range(world_size)]
171
+ opts = {
172
+ "ray_actor_options": {"num_gpus": 0, "num_cpus": config.num_cpus, "runtime_env": runtime_env},
173
+ "placement_group_bundles": bundles,
174
+ "placement_group_strategy": "STRICT_PACK",
175
+ }
176
+
177
+ # Per-model Ray Serve concurrency cap; only override the default when set.
178
+ # The reservation helpers read only the GPU/CPU keys, so this is inert there.
179
+ max_ongoing = config.max_ongoing_requests
180
+ if max_ongoing is None and config.loader == ModelLoader.llama_server:
181
+ parallel = config.llama_server_config.parallel if config.llama_server_config else 1
182
+ max_ongoing = parallel
183
+
184
+ if max_ongoing is not None:
185
+ opts["max_ongoing_requests"] = max_ongoing
186
+ return opts
@@ -0,0 +1,109 @@
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.deploy.actor_options import resolve_plugin_wheel
8
+ from modelship.infer.infer_config import ModelLoader, ModelshipConfig
9
+ from modelship.infer.model_resolver import check_model_source
10
+ from modelship.logging import get_logger
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 and validates only the merged result."""
51
+ with open(resolve_config_path(arg_path)) as f:
52
+ data = yaml.safe_load(f) or {}
53
+ if not isinstance(data, dict):
54
+ raise ValueError("models.yaml: top-level document must be a mapping with a 'models' key.")
55
+ models = data.get("models", [])
56
+ if not isinstance(models, list):
57
+ raise ValueError("models.yaml: 'models' must be a list.")
58
+ return models
59
+
60
+
61
+ def resolve_all_plugin_wheels(yml_conf: ModelshipConfig) -> dict[str, Path]:
62
+ """Pre-flight: resolve every referenced plugin wheel up front so a missing
63
+ wheel fails the whole startup before any Ray deploy is attempted."""
64
+ wheels: dict[str, Path] = {}
65
+ for cfg in yml_conf.models:
66
+ if cfg.loader == ModelLoader.custom and cfg.plugin and cfg.plugin not in wheels:
67
+ wheels[cfg.plugin] = resolve_plugin_wheel(cfg.plugin)
68
+ return wheels
69
+
70
+
71
+ def resolve_all_model_sources(yml_conf: ModelshipConfig) -> None:
72
+ """Pre-flight: check every built-in-loader model's source, without
73
+ downloading any weight bytes.
74
+
75
+ Populates `_pinned_source` (and, for llama_server, the mmproj pin) on each
76
+ config in place; actual download happens per-replica in
77
+ `BaseInfer.ensure_downloaded`. Raises on the first failure (auth,
78
+ missing repo, missing file, glob-no-match) so the operator sees it before
79
+ any Ray actor spins up.
80
+
81
+ Plugins (`loader=custom`) are skipped — they manage their own download.
82
+
83
+ Note: HF_HOME / VLLM_CACHE_ROOT / FLASHINFER_CACHE_DIR are set at module
84
+ load time in mship_deploy.py — `huggingface_hub.HF_HOME` is latched at
85
+ import, so setting them later doesn't take effect.
86
+ """
87
+ for cfg in yml_conf.models:
88
+ if cfg.loader == ModelLoader.custom:
89
+ continue
90
+ assert cfg.model is not None # validator guarantees this for built-in loaders
91
+ trust_remote_code = bool(cfg.vllm_engine_kwargs and cfg.vllm_engine_kwargs.trust_remote_code)
92
+ logger.info("Checking model source for '%s': %s", cfg.name, cfg.model)
93
+ cfg._pinned_source = check_model_source(cfg.model, trust_remote_code=trust_remote_code)
94
+ logger.info("Checked '%s' (revision=%s)", cfg.name, cfg._pinned_source.revision or "local")
95
+
96
+ if cfg.loader == ModelLoader.llama_server and cfg.llama_server_config and cfg.llama_server_config.mmproj:
97
+ logger.info("Checking mmproj source for '%s': %s", cfg.name, cfg.llama_server_config.mmproj)
98
+ cfg.llama_server_config._pinned_mmproj = check_model_source(
99
+ cfg.llama_server_config.mmproj, trust_remote_code=trust_remote_code
100
+ )
101
+
102
+ # GGUF is not supported on the vllm loader (vLLM 0.24 dropped in-tree
103
+ # GGUF). Reject early using the listed filename, before any download.
104
+ if cfg.loader == ModelLoader.vllm and cfg._pinned_source.resolves_to_gguf:
105
+ raise ValueError(
106
+ f"Model '{cfg.name}' resolves to a GGUF file, which the vllm loader does not support "
107
+ f"(vLLM 0.24 dropped in-tree GGUF). Use `loader: llama_server` for GGUF models, or point "
108
+ f"the vllm loader at a non-GGUF checkpoint (safetensors, or an AWQ/GPTQ/FP8 quant)."
109
+ )
@@ -0,0 +1,101 @@
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 merge(
46
+ effective_raw: list[dict],
47
+ input_raw: list[dict],
48
+ gateway_name: str,
49
+ mode: DeployMode,
50
+ ) -> list[dict]:
51
+ """Fold the user's input into the effective raw model set under *mode*.
52
+
53
+ - additive: union — append input dicts whose deployment name isn't already
54
+ present (identical config = idempotent skip; same name + different config =
55
+ a distinct deployment the gateway round-robins, preserved as today).
56
+ - reconcile: input replaces the effective set entirely.
57
+ """
58
+ if mode == "reconcile":
59
+ return list(input_raw)
60
+
61
+ present = {_deployment_name(d, gateway_name) for d in effective_raw}
62
+ merged = list(effective_raw)
63
+ for d in input_raw:
64
+ name = _deployment_name(d, gateway_name)
65
+ if name not in present:
66
+ merged.append(d)
67
+ present.add(name)
68
+ return merged
69
+
70
+
71
+ def deployment_names(raw_models: list[dict], gateway_name: str) -> set[str]:
72
+ """The deployment-name set for raw model dicts — the identity set of what's
73
+ under this gateway's effective management. Passed to the deploy plan so a
74
+ reconcile only removes deployments that WERE effective-managed (never legacy /
75
+ un-tracked deployments or another gateway's apps). Relies on the effective
76
+ config being per-gateway and the gateway being folded into each fingerprint."""
77
+ return {_deployment_name(d, gateway_name) for d in raw_models}
78
+
79
+
80
+ def to_config(raw_models: list[dict]) -> ModelshipConfig:
81
+ """Validate raw model dicts into a ModelshipConfig for the deploy path."""
82
+ return ModelshipConfig.model_validate({"models": raw_models})
83
+
84
+
85
+ def read_effective(store: StateStore, gateway_name: str) -> list[dict]:
86
+ """Return the persisted effective raw model set for *gateway_name* (empty if
87
+ none yet)."""
88
+ data = store.get(f"{_NAMESPACE}/{gateway_name}")
89
+ if not isinstance(data, dict):
90
+ return []
91
+ models = data.get("models", [])
92
+ if not isinstance(models, list):
93
+ logger.warning("Effective config for gateway %r has non-list 'models'; treating as empty.", gateway_name)
94
+ return []
95
+ return models
96
+
97
+
98
+ def write_effective(store: StateStore, gateway_name: str, raw_models: list[dict]) -> None:
99
+ """Persist the effective raw model set for *gateway_name*."""
100
+ store.set(f"{_NAMESPACE}/{gateway_name}", {"models": raw_models})
101
+ logger.info("Effective config for gateway %r now has %d model(s).", gateway_name, len(raw_models))