Coverage for cli/inference.py: 97.89%
390 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""
2Inference endpoint management for GCO CLI.
4Provides functionality to deploy, manage, and monitor inference endpoints
5across multi-region EKS clusters via the DynamoDB-backed reconciliation
6pattern (inference_monitor).
7"""
9from __future__ import annotations
11import logging
12from copy import deepcopy
13from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeGuard
15from .aws_client import get_aws_client
16from .config import GCOConfig, get_config
18# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
19# Generated at (UTC): 2026-07-18T01:03:40Z
20# Flowchart(s) generated from this file:
21# * ``InferenceManager.deploy`` -> ``diagrams/code_diagrams/cli/inference.InferenceManager_deploy.html``
22# (PNG: ``diagrams/code_diagrams/cli/inference.InferenceManager_deploy.png``)
23# * ``InferenceManager.canary_deploy`` -> ``diagrams/code_diagrams/cli/inference.InferenceManager_canary_deploy.html``
24# (PNG: ``diagrams/code_diagrams/cli/inference.InferenceManager_canary_deploy.png``)
25# Regenerate with ``python diagrams/code_diagrams/generate.py``.
26# <pyflowchart-code-diagram> END
29if TYPE_CHECKING:
30 from gco.services.inference_store import InferenceEndpointStore
32logger = logging.getLogger(__name__)
35# ---------------------------------------------------------------------------
36# Mooncake topology — optional endpoint-spec extension
37# ---------------------------------------------------------------------------
38#
39# An endpoint spec may carry an optional ``mooncake`` block describing
40# disaggregated prefill/decode (PD) serving and/or a shared KV-cache store.
41# The block is entirely additive: when it is absent the endpoint reconciles
42# exactly as it does today — one Deployment and one internal ClusterIP Service
43# behind the shared authenticated inference route.
44#
45# The definitions below describe the shape of that block (the dict written to
46# DynamoDB and read back by the per-region monitor) and the constant
47# vocabularies its enumerated fields draw from. Byte-size fields are authored
48# as base-10 integer decimal strings (see :func:`author_byte_size`) so they
49# round-trip through DynamoDB without being coerced to ``Decimal`` via a float
50# literal.
52#: Serving modes a ``mooncake`` block may declare.
53#: ``disaggregated`` splits prefill and decode; ``store`` runs a single
54#: KV-store instance; ``both`` composes the two.
55MOONCAKE_MODES: frozenset[str] = frozenset({"disaggregated", "store", "both"})
57#: KV transfer / store intents. ``rdma`` is the default high-performance
58#: intent: GCO schedules the pod on EFA and renders vLLM's point-to-point
59#: ``mooncake_protocol`` as ``efa``. ``tcp`` is the non-EFA fallback.
60MOONCAKE_TRANSFER_PROTOCOLS: frozenset[str] = frozenset({"rdma", "tcp"})
62#: KV-store offload tiers for spilling cache beyond GPU memory.
63MOONCAKE_OFFLOAD_TIERS: frozenset[str] = frozenset({"cpu", "disk", "none"})
65#: PD proxy request-scheduling strategies supported today.
66MOONCAKE_PROXY_SCHEDULING: frozenset[str] = frozenset({"round_robin"})
68#: Inclusive bounds for per-role replica counts in an XpYd topology.
69MOONCAKE_TOPOLOGY_MIN: int = 1
70MOONCAKE_TOPOLOGY_MAX: int = 1000
72#: Inclusive bounds for byte-size fields. The ceiling is the signed 64-bit
73#: maximum; authoring sizes as decimal strings in ``[MIN, MAX]`` keeps them out
74#: of float/Decimal coercion when they round-trip through DynamoDB.
75MOONCAKE_BYTE_SIZE_MIN: int = 0
76MOONCAKE_BYTE_SIZE_MAX: int = 9223372036854775807
78#: Transfer-engine defaults mirroring Mooncake's reference configuration.
79MOONCAKE_DEFAULT_BOOTSTRAP_BASE_PORT: int = 8998
80MOONCAKE_DEFAULT_NUM_WORKERS: int = 10
81MOONCAKE_DEFAULT_ABORT_REQUEST_TIMEOUT: int = 480
84class MooncakeTopology(TypedDict):
85 """An XpYd topology: ``prefill`` (X) and ``decode`` (Y) instance counts."""
87 prefill: int
88 decode: int
91class MooncakeStoreConfig(TypedDict, total=False):
92 """KV-cache store pool configuration.
94 ``global_segment_size`` and ``local_buffer_size`` are byte counts authored
95 as base-10 integer decimal strings. ``cold_tier_enabled`` opts this
96 endpoint into the asynchronous, per-region object-store cold tier; the
97 cold-tier bucket is resolved by the monitor from regional configuration and
98 is never a user-typed URI.
99 """
101 enabled: bool
102 metadata_server: str
103 master_server_address: str
104 protocol: Literal["rdma", "tcp"]
105 device_name: str
106 global_segment_size: str
107 local_buffer_size: str
108 offload: Literal["cpu", "disk", "none"]
109 cold_tier_enabled: bool
112class MooncakeTransferConfig(TypedDict, total=False):
113 """RDMA/TCP transfer-engine configuration for KV cache movement."""
115 protocol: Literal["rdma", "tcp"]
116 device_name: str
117 num_workers: int
118 bootstrap_base_port: int
119 abort_request_timeout: int
122class MooncakeProxyConfig(TypedDict, total=False):
123 """PD proxy configuration. ``admin_api_key_secret`` names the Kubernetes
124 Secret holding the proxy admin key; the key value is never carried on the
125 endpoint spec."""
127 image: str
128 scheduling: Literal["round_robin"]
129 admin_api_key_secret: str
132class MooncakeRoleAutoscaling(TypedDict, total=False):
133 """Per-role autoscaling bounds and metrics for one of prefill/decode."""
135 min_replicas: int
136 max_replicas: int
137 metrics: list[dict[str, Any]]
140class MooncakeAutoscalingConfig(TypedDict, total=False):
141 """Optional per-role pod autoscaling. When absent the topology is static."""
143 enabled: bool
144 prefill: MooncakeRoleAutoscaling
145 decode: MooncakeRoleAutoscaling
148class MooncakeSpec(TypedDict, total=False):
149 """The optional ``mooncake`` block carried on an endpoint spec dict."""
151 mode: Literal["disaggregated", "store", "both"]
152 topology: MooncakeTopology
153 store: MooncakeStoreConfig
154 transfer: MooncakeTransferConfig
155 proxy: MooncakeProxyConfig
156 autoscaling: MooncakeAutoscalingConfig
159def author_byte_size(value: int | str) -> str:
160 """Render a byte-size value as a canonical base-10 integer decimal string.
162 Mooncake store/transfer sizes (segment size, local buffer) are carried on
163 the endpoint spec as digit-only strings so they survive the DynamoDB
164 round-trip without being coerced to ``Decimal`` through a float literal.
166 Accepts a non-negative ``int`` or a string of base-10 ASCII digits and
167 returns the same whole number as ``str``. The value must fall in
168 ``[MOONCAKE_BYTE_SIZE_MIN, MOONCAKE_BYTE_SIZE_MAX]``. Signs, decimal
169 points, exponents, floats, booleans, and any non-digit text are not
170 accepted.
172 Raises:
173 ValueError: when ``value`` cannot be authored as an in-range base-10
174 integer.
175 """
176 # ``bool`` is a subclass of ``int``; reject it explicitly so ``True``/``False``
177 # never masquerade as 1/0 byte sizes.
178 if isinstance(value, bool):
179 raise ValueError(f"byte-size value must be an integer, got bool: {value!r}")
181 if isinstance(value, int):
182 size = value
183 elif isinstance(value, str):
184 text = value.strip()
185 if not text or any(ch not in "0123456789" for ch in text):
186 raise ValueError(
187 "byte-size value must be a base-10 integer string "
188 f"(ASCII digits only, no sign, point, or exponent), got {value!r}"
189 )
190 size = int(text)
191 else:
192 raise ValueError(
193 f"byte-size value must be an int or a base-10 digit string, got {type(value).__name__}"
194 )
196 if not MOONCAKE_BYTE_SIZE_MIN <= size <= MOONCAKE_BYTE_SIZE_MAX:
197 raise ValueError(
198 "byte-size value out of range "
199 f"[{MOONCAKE_BYTE_SIZE_MIN}, {MOONCAKE_BYTE_SIZE_MAX}]: {size}"
200 )
202 return str(size)
205#: Byte-size fields a ``mooncake`` store block may carry. Each is authored as a
206#: base-10 integer decimal string via :func:`author_byte_size`.
207_MOONCAKE_STORE_BYTE_SIZE_FIELDS: tuple[str, ...] = (
208 "global_segment_size",
209 "local_buffer_size",
210)
212#: Modes that run a split prefill/decode topology and therefore require a
213#: valid ``topology`` and may carry per-role autoscaling.
214_MOONCAKE_DISAGGREGATED_MODES: frozenset[str] = frozenset({"disaggregated", "both"})
217def _is_plain_int(value: Any) -> TypeGuard[int]:
218 """True when ``value`` is an ``int`` and not a ``bool``.
220 ``bool`` is a subclass of ``int``; counts and replica bounds must be real
221 integers, so ``True``/``False`` are not accepted as 1/0.
222 """
223 return isinstance(value, int) and not isinstance(value, bool)
226def _validate_role_autoscaling_bounds(role: str, role_block: dict[str, Any]) -> None:
227 """Validate one role's ``min_replicas``/``max_replicas`` bounds.
229 Raises :class:`ValueError` naming the violated bound. ``min_replicas`` must
230 be an integer ``>= 1`` and ``max_replicas`` an integer no smaller than the
231 effective minimum (which defaults to 1 when ``min_replicas`` is absent).
232 """
233 min_replicas = role_block.get("min_replicas")
234 max_replicas = role_block.get("max_replicas")
236 if min_replicas is not None:
237 if not _is_plain_int(min_replicas):
238 raise ValueError(
239 f"mooncake.autoscaling.{role}.min_replicas must be an integer, got {min_replicas!r}"
240 )
241 if min_replicas < 1:
242 raise ValueError(
243 f"mooncake.autoscaling.{role}.min_replicas must be >= 1, got {min_replicas}"
244 )
246 if max_replicas is not None: 246 ↛ exitline 246 didn't return from function '_validate_role_autoscaling_bounds' because the condition on line 246 was always true
247 if not _is_plain_int(max_replicas):
248 raise ValueError(
249 f"mooncake.autoscaling.{role}.max_replicas must be an integer, got {max_replicas!r}"
250 )
251 effective_min = min_replicas if _is_plain_int(min_replicas) else 1
252 if max_replicas < effective_min:
253 raise ValueError(
254 f"mooncake.autoscaling.{role}.max_replicas ({max_replicas}) "
255 f"must be >= min_replicas ({effective_min})"
256 )
259def validate_mooncake_spec(mooncake: dict[str, Any]) -> None:
260 """Validate a ``mooncake`` endpoint-spec block, failing fast.
262 Raises :class:`ValueError` on the first rejected field, naming the
263 offending field so the caller can correct it. The check is pure — it reads
264 nothing and writes nothing — so a caller that validates before persisting
265 leaves any previously stored spec untouched when a block is rejected.
267 The rules enforced here are:
269 * ``mode`` must be one of the supported serving modes
270 (:data:`MOONCAKE_MODES`).
271 * ``transfer`` must be a mapping when present; ``protocol`` must be one of
272 :data:`MOONCAKE_TRANSFER_PROTOCOLS` and ``device_name`` must be a string
273 (the empty string requests automatic interface detection).
274 * Store byte-size fields must author as in-range base-10 integers.
275 * ``disaggregated``/``both`` modes require integer ``topology.prefill`` and
276 ``topology.decode`` in
277 ``[MOONCAKE_TOPOLOGY_MIN, MOONCAKE_TOPOLOGY_MAX]``.
278 * ``store.cold_tier_enabled`` may be true only while ``store.enabled`` is
279 true (the cold tier extends the hot store).
280 * Autoscaling may be enabled only for ``disaggregated``/``both`` modes, and
281 each present role's ``min_replicas``/``max_replicas`` must satisfy
282 ``min_replicas >= 1`` and ``max_replicas >= min_replicas``.
283 """
284 if not isinstance(mooncake, dict):
285 raise ValueError("mooncake block must be a mapping")
287 mode = mooncake.get("mode")
288 if mode not in MOONCAKE_MODES:
289 allowed = ", ".join(sorted(MOONCAKE_MODES))
290 raise ValueError(f"mooncake.mode must be one of {{{allowed}}}, got {mode!r}")
292 store = mooncake.get("store")
293 if store is not None and not isinstance(store, dict):
294 raise ValueError("mooncake.store must be a mapping")
296 transfer = mooncake.get("transfer")
297 if transfer is not None and not isinstance(transfer, dict):
298 raise ValueError("mooncake.transfer must be a mapping")
299 if isinstance(transfer, dict):
300 protocol = transfer.get("protocol", "rdma")
301 if protocol not in MOONCAKE_TRANSFER_PROTOCOLS:
302 allowed = ", ".join(sorted(MOONCAKE_TRANSFER_PROTOCOLS))
303 raise ValueError(
304 f"mooncake.transfer.protocol must be one of {{{allowed}}}, got {protocol!r}"
305 )
306 device_name = transfer.get("device_name", "")
307 if not isinstance(device_name, str):
308 raise ValueError(f"mooncake.transfer.device_name must be a string, got {device_name!r}")
310 # Byte-size fields must author cleanly; surface the offending field name.
311 if isinstance(store, dict):
312 for field in _MOONCAKE_STORE_BYTE_SIZE_FIELDS:
313 if field in store:
314 try:
315 author_byte_size(store[field])
316 except ValueError as exc:
317 raise ValueError(f"mooncake.store.{field}: {exc}") from exc
319 # Split topologies need integer prefill/decode counts in range.
320 if mode in _MOONCAKE_DISAGGREGATED_MODES:
321 topology = mooncake.get("topology")
322 if not isinstance(topology, dict):
323 raise ValueError(
324 f"mooncake.topology is required for mode {mode!r} with integer "
325 "'prefill' and 'decode' counts"
326 )
327 for field in ("prefill", "decode"):
328 count = topology.get(field)
329 if not _is_plain_int(count):
330 raise ValueError(
331 f"mooncake.topology.{field} must be an integer in "
332 f"[{MOONCAKE_TOPOLOGY_MIN}, {MOONCAKE_TOPOLOGY_MAX}], "
333 f"got {count!r}"
334 )
335 if not MOONCAKE_TOPOLOGY_MIN <= count <= MOONCAKE_TOPOLOGY_MAX:
336 raise ValueError(
337 f"mooncake.topology.{field} out of range "
338 f"[{MOONCAKE_TOPOLOGY_MIN}, {MOONCAKE_TOPOLOGY_MAX}]: {count}"
339 )
341 # The cold tier extends the hot store; it cannot be enabled on its own.
342 if (
343 isinstance(store, dict)
344 and store.get("cold_tier_enabled") is True
345 and store.get("enabled") is not True
346 ):
347 raise ValueError(
348 "mooncake.store.cold_tier_enabled requires mooncake.store.enabled to be true"
349 )
351 autoscaling = mooncake.get("autoscaling")
352 if autoscaling is not None:
353 if not isinstance(autoscaling, dict):
354 raise ValueError("mooncake.autoscaling must be a mapping")
355 if autoscaling.get("enabled") is True and mode not in _MOONCAKE_DISAGGREGATED_MODES:
356 raise ValueError(
357 "mooncake.autoscaling.enabled requires a 'disaggregated' or "
358 f"'both' mode, got {mode!r}"
359 )
360 for role in ("prefill", "decode"):
361 role_block = autoscaling.get(role)
362 if role_block is None:
363 continue
364 if not isinstance(role_block, dict):
365 raise ValueError(f"mooncake.autoscaling.{role} must be a mapping")
366 _validate_role_autoscaling_bounds(role, role_block)
369class InferenceManager:
370 """Manages inference endpoints via the DynamoDB store."""
372 def __init__(self, config: GCOConfig | None = None):
373 self.config = config or get_config()
374 self._aws_client = get_aws_client(config)
376 def _get_store(self, region: str | None = None) -> InferenceEndpointStore:
377 """Get an InferenceEndpointStore for the global region."""
378 from gco.services.inference_store import InferenceEndpointStore
380 # Use the global region for DynamoDB (same as job store)
381 store_region = region or self.config.global_region
382 return InferenceEndpointStore(region=store_region)
384 def _build_mooncake_block(
385 self,
386 *,
387 mode: str,
388 prefill_replicas: int,
389 decode_replicas: int,
390 store: dict[str, Any] | None,
391 transfer: dict[str, Any] | None,
392 proxy: dict[str, Any] | None,
393 autoscaling: dict[str, Any] | None,
394 default_proxy_image: str | None = None,
395 ) -> dict[str, Any]:
396 """Assemble and validate an optional ``spec.mooncake`` block.
398 Composes the topology and any supplied store/transfer/proxy/autoscaling
399 sub-blocks into a single mapping, authoring store byte-size fields as
400 base-10 integer decimal strings so they round-trip through DynamoDB,
401 then validates the result. Validation is pure and runs before the
402 caller persists anything, so a rejected block leaves any previously
403 stored spec untouched. Raises :class:`ValueError` — naming the offending
404 field — when the mode is unsupported or any field is invalid.
406 The store-bearing modes (``store`` and ``both``) default the store to
407 enabled so the shared master address is wired in (the ``both``-mode
408 MultiConnector's store half depends on it), and split modes
409 (``disaggregated`` and ``both``) default the prefill-decode proxy image
410 to ``default_proxy_image`` when the caller supplies no explicit proxy
411 image.
412 """
413 block: dict[str, Any] = {"mode": mode}
415 # Split modes carry an XpYd topology; a single-instance store does not.
416 if mode in _MOONCAKE_DISAGGREGATED_MODES:
417 block["topology"] = {
418 "prefill": prefill_replicas,
419 "decode": decode_replicas,
420 }
422 # The store-bearing modes (store and both) only function with the KV
423 # store enabled: the both-mode MultiConnector's store half is wired to
424 # the shared master address, which the monitor renders only for an
425 # enabled store. So a store block is always present for those modes,
426 # defaulting enabled to True; an explicit store block still tunes
427 # offload, sizes, and the cold tier.
428 store_block = dict(store) if store is not None else None
429 if mode in ("store", "both"):
430 store_block = dict(store_block or {})
431 store_block.setdefault("enabled", True)
432 if store_block is not None:
433 # Author byte-size fields as canonical decimal strings up front so
434 # the persisted spec round-trips through DynamoDB without float or
435 # Decimal coercion. Authoring also fails fast on bad inputs.
436 for field in _MOONCAKE_STORE_BYTE_SIZE_FIELDS:
437 if field in store_block:
438 try:
439 store_block[field] = author_byte_size(store_block[field])
440 except ValueError as exc:
441 raise ValueError(f"mooncake.store.{field}: {exc}") from exc
442 block["store"] = store_block
444 if transfer is not None:
445 block["transfer"] = dict(transfer)
447 # Split modes are fronted by the prefill-decode proxy, which needs a
448 # container image. Default it to the same image the role pods serve from
449 # (the upstream vLLM image bundles the reference proxy) so a split deploy
450 # stands up without a separate proxy image; an explicit proxy image
451 # still wins.
452 proxy_block = dict(proxy) if proxy is not None else None
453 if mode in _MOONCAKE_DISAGGREGATED_MODES and default_proxy_image:
454 proxy_block = dict(proxy_block or {})
455 proxy_block.setdefault("image", default_proxy_image)
456 if proxy_block is not None:
457 block["proxy"] = proxy_block
459 if autoscaling is not None:
460 block["autoscaling"] = dict(autoscaling)
462 # Fail fast before persisting: rejects unsupported modes (naming the
463 # allowed values) and every other invalid field.
464 validate_mooncake_spec(block)
465 return block
467 def deploy(
468 self,
469 endpoint_name: str,
470 image: str | None = None,
471 target_regions: list[str] | None = None,
472 replicas: int = 1,
473 gpu_count: int = 1,
474 gpu_type: str | None = None,
475 port: int = 8000,
476 model_path: str | None = None,
477 model_source: str | None = None,
478 health_check_path: str = "/health",
479 env: dict[str, str] | None = None,
480 namespace: str = "gco-inference",
481 labels: dict[str, str] | None = None,
482 autoscaling: dict[str, Any] | None = None,
483 capacity_type: str | None = None,
484 extra_args: list[str] | None = None,
485 accelerator: str = "nvidia",
486 node_selector: dict[str, str] | None = None,
487 rewrite_image: bool = True,
488 *,
489 mooncake_mode: str | None = None,
490 prefill_replicas: int = 1,
491 decode_replicas: int = 1,
492 mooncake_store: dict[str, Any] | None = None,
493 mooncake_transfer: dict[str, Any] | None = None,
494 mooncake_proxy: dict[str, Any] | None = None,
495 mooncake_autoscaling: dict[str, Any] | None = None,
496 ) -> dict[str, Any]:
497 """
498 Deploy an inference endpoint to one or more regions.
500 The endpoint spec is written to DynamoDB. The inference_monitor
501 in each target region picks it up and creates the K8s resources.
503 Args:
504 endpoint_name: Unique name for the endpoint
505 image: Container image (e.g. vllm/vllm-openai:v0.26.0). Optional
506 when ``mooncake_mode`` is set: a disaggregated/store deploy
507 with no image falls back to the default upstream
508 Mooncake-enabled vLLM image. A plain deploy still requires an
509 image.
510 target_regions: Regions to deploy to (default: all deployed regions)
511 replicas: Number of replicas per region
512 gpu_count: GPUs per replica
513 gpu_type: GPU instance type hint for node selector
514 port: Container port
515 model_path: EFS path for model weights
516 health_check_path: Health check endpoint path
517 env: Environment variables
518 namespace: Kubernetes namespace
519 labels: Labels for the endpoint
520 rewrite_image: When True (the default), rewrite ECR URIs in
521 ``image`` to target each region's local replica. Non-ECR
522 refs (Docker Hub, GHCR, etc.) are left unchanged. When
523 False, the URI is written verbatim to every region's
524 spec — the operator is responsible for cross-region
525 pulls. Per-region rewrites are stored under a
526 ``region_overrides`` map on the spec keyed by region.
527 mooncake_mode: When set to one of ``disaggregated``, ``store``,
528 or ``both``, build and persist a ``spec.mooncake`` block for
529 disaggregated prefill/decode serving and/or a shared KV-cache
530 store. An unsupported value is rejected before anything is
531 persisted.
532 prefill_replicas: X in an XpYd topology — prefill instance count
533 for split (``disaggregated``/``both``) modes.
534 decode_replicas: Y in an XpYd topology — decode instance count for
535 split modes.
536 mooncake_store: Optional KV-store pool configuration merged into
537 ``spec.mooncake.store``. Byte-size fields are authored as
538 base-10 integer decimal strings so they round-trip through
539 DynamoDB.
540 mooncake_transfer: Optional Mooncake transfer intent and network
541 device. ``protocol`` accepts ``rdma`` (the default; scheduled
542 on EFA and rendered to vLLM as ``mooncake_protocol=efa``) or
543 ``tcp`` (no EFA placement). ``device_name`` is forwarded to
544 both the connector and mounted Mooncake configuration; an
545 empty string lets Mooncake auto-detect it.
546 mooncake_proxy: Optional PD proxy configuration merged into
547 ``spec.mooncake.proxy``.
548 mooncake_autoscaling: Optional per-role autoscaling configuration
549 merged into ``spec.mooncake.autoscaling``.
551 Returns:
552 Created endpoint record
553 """
554 # Build the optional mooncake block first and validate it before any
555 # persistence so a rejected block leaves any stored spec untouched. A
556 # disaggregated/store deploy without an explicit image falls back to
557 # the default upstream Mooncake-enabled vLLM image.
558 mooncake_block: dict[str, Any] | None = None
559 if mooncake_mode is not None:
560 # Resolve the image before building the block so a split mode's
561 # prefill-decode proxy can default to the same image the role pods
562 # serve from (the upstream vLLM image bundles the reference proxy).
563 if image is None:
564 from .images import default_disaggregated_image
566 image = default_disaggregated_image(config=self.config)
567 mooncake_block = self._build_mooncake_block(
568 mode=mooncake_mode,
569 prefill_replicas=prefill_replicas,
570 decode_replicas=decode_replicas,
571 store=mooncake_store,
572 transfer=mooncake_transfer,
573 proxy=mooncake_proxy,
574 autoscaling=mooncake_autoscaling,
575 default_proxy_image=image,
576 )
578 if image is None:
579 raise ValueError(
580 "an image is required (pass image, or set mooncake_mode to use "
581 "the default upstream Mooncake-enabled vLLM image)"
582 )
584 if not target_regions:
585 stacks = self._aws_client.discover_regional_stacks()
586 target_regions = list(stacks.keys())
587 if not target_regions:
588 raise ValueError("No deployed regions found. Deploy infrastructure first.")
590 # Per-region image-URI rewrites for ECR refs. Each target region
591 # gets the local replica's URI on its own spec, so the
592 # inference_monitor's pod-spec materialiser pulls in-region
593 # rather than across the WAN. Non-ECR URIs come back unchanged
594 # from the helper, so this is a no-op for Docker Hub / GHCR refs.
595 #
596 # The helper lives in ``cli._image_uri`` rather than ``cli.images``
597 # so this import doesn't create a module-level cycle:
598 # ``cli.images`` itself imports the same helper. ``cli._image_uri``
599 # is a leaf module with no project-side dependencies.
600 region_image_map: dict[str, str] = {}
601 if rewrite_image:
602 from ._image_uri import rewrite_image_uri_for_region
604 for region in target_regions:
605 region_image_map[region] = rewrite_image_uri_for_region(image, region)
607 spec = {
608 "image": image,
609 "port": port,
610 "replicas": replicas,
611 "gpu_count": gpu_count,
612 "health_check_path": health_check_path,
613 }
614 # Preserve the rewrite map on the spec so the inference_monitor
615 # service can pick the right URI per region when materialising
616 # pods. When ``rewrite_image=False`` no map is set and the flat
617 # ``image`` field is the only source.
618 if region_image_map and any(uri != image for uri in region_image_map.values()):
619 spec["region_image_uris"] = region_image_map
620 if gpu_type:
621 spec["gpu_type"] = gpu_type
622 if model_path:
623 spec["model_path"] = model_path
624 if model_source:
625 spec["model_source"] = model_source
626 if env:
627 spec["env"] = env
628 if autoscaling:
629 spec["autoscaling"] = autoscaling
630 if capacity_type:
631 spec["capacity_type"] = capacity_type
632 if extra_args:
633 spec["args"] = extra_args
634 if accelerator != "nvidia":
635 spec["accelerator"] = accelerator
636 if node_selector:
637 spec["node_selector"] = node_selector
638 if mooncake_block is not None:
639 spec["mooncake"] = mooncake_block
641 store = self._get_store()
642 result: dict[str, Any] = store.create_endpoint(
643 endpoint_name=endpoint_name,
644 spec=spec,
645 target_regions=target_regions,
646 namespace=namespace,
647 labels=labels,
648 )
649 return result
651 def list_endpoints(
652 self,
653 desired_state: str | None = None,
654 region: str | None = None,
655 ) -> list[dict[str, Any]]:
656 """List all inference endpoints."""
657 store = self._get_store()
658 result: list[dict[str, Any]] = store.list_endpoints(
659 desired_state=desired_state,
660 target_region=region,
661 )
662 return result
664 def get_endpoint(self, endpoint_name: str) -> dict[str, Any] | None:
665 """Get details of a specific endpoint."""
666 store = self._get_store()
667 result: dict[str, Any] | None = store.get_endpoint(endpoint_name)
668 return result
670 def scale(self, endpoint_name: str, replicas: int) -> dict[str, Any] | None:
671 """Scale an endpoint to a new replica count."""
672 store = self._get_store()
673 result: dict[str, Any] | None = store.scale_endpoint(endpoint_name, replicas)
674 return result
676 def set_topology(
677 self,
678 endpoint_name: str,
679 prefill: int,
680 decode: int,
681 ) -> dict[str, Any] | None:
682 """Resize a disaggregated endpoint's prefill/decode topology.
684 Updates ``spec.mooncake.topology`` to the new XpYd counts and
685 re-triggers reconciliation (via :meth:`InferenceEndpointStore.update_spec`,
686 which flips ``desired_state`` to ``deploying``) so the per-region
687 monitor adjusts the prefill and decode role replica counts.
689 Both counts must be integers in the inclusive range
690 ``[MOONCAKE_TOPOLOGY_MIN, MOONCAKE_TOPOLOGY_MAX]``. The counts are
691 validated before anything is read or written, so a rejected request
692 names the offending count and leaves the stored topology and
693 ``desired_state`` untouched.
695 Args:
696 endpoint_name: Name of the disaggregated endpoint to resize.
697 prefill: New prefill (X) instance count.
698 decode: New decode (Y) instance count.
700 Returns:
701 The updated endpoint record, or ``None`` when no endpoint with
702 ``endpoint_name`` exists.
704 Raises:
705 ValueError: when ``prefill`` or ``decode`` is not an integer in
706 ``[MOONCAKE_TOPOLOGY_MIN, MOONCAKE_TOPOLOGY_MAX]``.
707 """
708 # Validate before any read or write so a bad count names the offending
709 # field and leaves the stored topology and desired_state unchanged.
710 for field, count in (("prefill", prefill), ("decode", decode)):
711 if not _is_plain_int(count):
712 raise ValueError(
713 f"topology {field} count must be an integer in "
714 f"[{MOONCAKE_TOPOLOGY_MIN}, {MOONCAKE_TOPOLOGY_MAX}], "
715 f"got {count!r}"
716 )
717 if not MOONCAKE_TOPOLOGY_MIN <= count <= MOONCAKE_TOPOLOGY_MAX:
718 raise ValueError(
719 f"topology {field} count out of range "
720 f"[{MOONCAKE_TOPOLOGY_MIN}, {MOONCAKE_TOPOLOGY_MAX}]: {count}"
721 )
723 store = self._get_store()
724 endpoint = store.get_endpoint(endpoint_name)
725 if not endpoint:
726 return None
728 spec = endpoint.get("spec", {})
729 # Preserve any existing mooncake sub-fields and replace only the
730 # topology counts.
731 mooncake = dict(spec.get("mooncake") or {})
732 mooncake["topology"] = {"prefill": prefill, "decode": decode}
733 spec["mooncake"] = mooncake
735 result: dict[str, Any] | None = store.update_spec(endpoint_name, spec)
736 return result
738 def configure_store(
739 self,
740 endpoint_name: str,
741 store_config: dict[str, Any],
742 ) -> dict[str, Any] | None:
743 """Update an endpoint's KV-cache store configuration.
745 Merges ``store_config`` into ``spec.mooncake.store`` and re-triggers
746 reconciliation (via :meth:`InferenceEndpointStore.update_spec`, which
747 flips ``desired_state`` to ``deploying``) so the per-region monitor
748 picks up the new store settings.
750 Store byte-size fields are authored as base-10 integer decimal strings
751 (so they round-trip through DynamoDB without float/Decimal coercion)
752 and the resulting ``mooncake`` block is validated before anything is
753 written. A rejected configuration names the offending field and leaves
754 the stored spec untouched.
756 Args:
757 endpoint_name: Name of the endpoint to reconfigure.
758 store_config: KV-store pool settings merged into
759 ``spec.mooncake.store``.
761 Returns:
762 The updated endpoint record, or ``None`` when no endpoint with
763 ``endpoint_name`` exists.
765 Raises:
766 ValueError: when the resulting ``mooncake`` block is invalid (for
767 example an out-of-range byte-size field).
768 """
769 store = self._get_store()
770 endpoint = store.get_endpoint(endpoint_name)
771 if not endpoint:
772 return None
774 spec = endpoint.get("spec", {})
775 # Preserve any existing mooncake sub-fields and replace only the store
776 # block, authoring byte-size fields as canonical decimal strings.
777 mooncake = dict(spec.get("mooncake") or {})
778 store_block = dict(store_config)
779 for field in _MOONCAKE_STORE_BYTE_SIZE_FIELDS:
780 if field in store_block:
781 try:
782 store_block[field] = author_byte_size(store_block[field])
783 except ValueError as exc:
784 raise ValueError(f"mooncake.store.{field}: {exc}") from exc
785 mooncake["store"] = store_block
786 spec["mooncake"] = mooncake
788 # Fail fast before persisting so a rejected block leaves the stored
789 # spec untouched.
790 validate_mooncake_spec(mooncake)
792 result: dict[str, Any] | None = store.update_spec(endpoint_name, spec)
793 return result
795 def stop(self, endpoint_name: str) -> dict[str, Any] | None:
796 """Stop an endpoint (scale to zero, keep resources)."""
797 store = self._get_store()
798 result: dict[str, Any] | None = store.update_desired_state(endpoint_name, "stopped")
799 return result
801 def start(self, endpoint_name: str) -> dict[str, Any] | None:
802 """Start a stopped endpoint."""
803 store = self._get_store()
804 result: dict[str, Any] | None = store.update_desired_state(endpoint_name, "running")
805 return result
807 def delete(self, endpoint_name: str) -> dict[str, Any] | None:
808 """Mark an endpoint for deletion (inference_monitor cleans up)."""
809 store = self._get_store()
810 result: dict[str, Any] | None = store.update_desired_state(endpoint_name, "deleted")
811 return result
813 def update_image(self, endpoint_name: str, image: str) -> dict[str, Any] | None:
814 """Update the container image for an endpoint."""
815 if not isinstance(image, str) or not image.strip():
816 raise ValueError("Image must be a non-empty string")
818 store = self._get_store()
819 endpoint = store.get_endpoint(endpoint_name)
820 if not endpoint:
821 return None
822 raw_spec = endpoint.get("spec")
823 if not isinstance(raw_spec, dict): 823 ↛ 824line 823 didn't jump to line 824 because the condition on line 823 was never true
824 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec")
825 spec = deepcopy(raw_spec)
826 spec["image"] = image.strip()
827 # A direct image update is global. Stale regional rewrites would take
828 # precedence in the monitor and silently keep serving the old image.
829 spec.pop("region_image_uris", None)
830 result: dict[str, Any] | None = store.update_spec(endpoint_name, spec)
831 return result
833 def add_region(self, endpoint_name: str, region: str) -> dict[str, Any] | None:
834 """Add a region to an existing endpoint."""
835 from datetime import UTC, datetime
837 store = self._get_store()
838 endpoint = store.get_endpoint(endpoint_name)
839 if not endpoint:
840 return None
841 regions = endpoint.get("target_regions", [])
842 if region not in regions:
843 regions.append(region)
844 # Update via raw DynamoDB update
845 try:
846 response = store._table.update_item(
847 Key={"endpoint_name": endpoint_name},
848 UpdateExpression="SET target_regions = :r, updated_at = :u",
849 ExpressionAttributeValues={
850 ":r": regions,
851 ":u": datetime.now(UTC).isoformat(),
852 },
853 ReturnValues="ALL_NEW",
854 )
855 result: dict[str, Any] | None = response.get("Attributes")
856 return result
857 except Exception as e:
858 logger.error("Failed to add region: %s", e)
859 return None
861 def remove_region(self, endpoint_name: str, region: str) -> dict[str, Any] | None:
862 """Remove a region from an existing endpoint."""
863 store = self._get_store()
864 endpoint = store.get_endpoint(endpoint_name)
865 if not endpoint:
866 return None
867 regions = endpoint.get("target_regions", [])
868 if region in regions:
869 regions.remove(region)
870 try:
871 from datetime import UTC, datetime
873 response = store._table.update_item(
874 Key={"endpoint_name": endpoint_name},
875 UpdateExpression="SET target_regions = :r, updated_at = :u",
876 ExpressionAttributeValues={
877 ":r": regions,
878 ":u": datetime.now(UTC).isoformat(),
879 },
880 ReturnValues="ALL_NEW",
881 )
882 result: dict[str, Any] | None = response.get("Attributes")
883 return result
884 except Exception as e:
885 logger.error("Failed to remove region: %s", e)
886 return None
888 def canary_deploy(
889 self,
890 endpoint_name: str,
891 image: str,
892 weight: int = 10,
893 replicas: int = 1,
894 ) -> dict[str, Any] | None:
895 """Start a canary deployment for an existing classic endpoint.
897 Creates a canary variant with the new image receiving ``weight``%
898 of traffic. Mooncake endpoints are excluded because their split-role
899 topology cannot be represented by the classic canary Deployment.
901 Args:
902 endpoint_name: Existing endpoint to canary
903 image: New container image for the canary
904 weight: Percentage of traffic to route to canary (1-99)
905 replicas: Positive number of canary replicas
907 Returns:
908 Updated endpoint record, or None if endpoint not found
909 """
910 if not isinstance(image, str) or not image.strip():
911 raise ValueError("Canary image must be a non-empty string")
912 if not _is_plain_int(weight) or not 1 <= weight <= 99:
913 raise ValueError("Canary weight must be an integer between 1 and 99")
914 if not _is_plain_int(replicas) or replicas < 1:
915 raise ValueError("Canary replicas must be a positive integer")
917 store = self._get_store()
918 endpoint = store.get_endpoint(endpoint_name)
919 if not endpoint:
920 return None
922 if endpoint.get("desired_state") not in ("running", "deploying"):
923 raise ValueError(
924 f"Cannot canary an endpoint in '{endpoint.get('desired_state')}' state. "
925 "Endpoint must be running or deploying."
926 )
928 raw_spec = endpoint.get("spec")
929 if not isinstance(raw_spec, dict): 929 ↛ 930line 929 didn't jump to line 930 because the condition on line 929 was never true
930 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec")
931 if "mooncake" in raw_spec:
932 raise ValueError("Canary deployments are not supported for Mooncake endpoints")
934 # Never mutate the object returned by the store; callers and test
935 # doubles may retain it as shared state.
936 spec = deepcopy(raw_spec)
937 spec["canary"] = {
938 "image": image.strip(),
939 "weight": weight,
940 "replicas": replicas,
941 }
943 result: dict[str, Any] | None = store.update_spec(endpoint_name, spec)
944 return result
946 def promote_canary(self, endpoint_name: str) -> dict[str, Any] | None:
947 """Promote a classic canary to primary and remove its deployment."""
948 store = self._get_store()
949 endpoint = store.get_endpoint(endpoint_name)
950 if not endpoint:
951 return None
953 raw_spec = endpoint.get("spec")
954 if not isinstance(raw_spec, dict): 954 ↛ 955line 954 didn't jump to line 955 because the condition on line 954 was never true
955 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec")
956 if "mooncake" in raw_spec:
957 raise ValueError("Canary promotion is not supported for Mooncake endpoints")
959 canary = raw_spec.get("canary")
960 if not isinstance(canary, dict):
961 raise ValueError(f"Endpoint '{endpoint_name}' has no active canary deployment")
962 if "image" not in canary:
963 raise ValueError(
964 f"Canary deployment for '{endpoint_name}' is missing the 'image' field"
965 )
966 canary_image = canary["image"]
967 if not isinstance(canary_image, str) or not canary_image.strip():
968 raise ValueError(
969 f"Canary deployment for '{endpoint_name}' has an invalid 'image' field"
970 )
972 spec = deepcopy(raw_spec)
973 spec["image"] = canary_image.strip()
974 spec.pop("canary", None)
975 # The canary image is explicit and global. Existing per-region primary
976 # rewrites point at the superseded image and must not take precedence.
977 spec.pop("region_image_uris", None)
979 result: dict[str, Any] | None = store.update_spec(endpoint_name, spec)
980 return result
982 def rollback_canary(self, endpoint_name: str) -> dict[str, Any] | None:
983 """Remove the canary deployment, keeping the primary unchanged."""
984 store = self._get_store()
985 endpoint = store.get_endpoint(endpoint_name)
986 if not endpoint:
987 return None
989 raw_spec = endpoint.get("spec")
990 if not isinstance(raw_spec, dict): 990 ↛ 991line 990 didn't jump to line 991 because the condition on line 990 was never true
991 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec")
992 if "canary" not in raw_spec:
993 raise ValueError(f"Endpoint '{endpoint_name}' has no active canary deployment")
995 # Rollback is deliberately allowed for a legacy invalid
996 # Mooncake-plus-canary record so an operator can repair it.
997 spec = deepcopy(raw_spec)
998 spec.pop("canary", None)
999 result: dict[str, Any] | None = store.update_spec(endpoint_name, spec)
1000 return result
1003def get_inference_manager(config: GCOConfig | None = None) -> InferenceManager:
1004 """Factory function for InferenceManager."""
1005 return InferenceManager(config)