Coverage for cli / inference.py: 100.00%

497 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1""" 

2Inference endpoint management for GCO CLI. 

3 

4Provides functionality to deploy, manage, and monitor inference endpoints 

5across multi-region EKS clusters via the DynamoDB-backed reconciliation 

6pattern (inference_monitor). 

7""" 

8 

9from __future__ import annotations 

10 

11import logging 

12import secrets 

13from copy import deepcopy 

14from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeGuard 

15 

16from .aws_client import get_aws_client 

17from .config import GCOConfig, get_config 

18 

19# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

20# Generated at (UTC): 2026-09-13T13:44:22Z 

21# Generated from Git commit: c49331669c66625fecfecf44ae6ab5f95afbfcb4 

22# Flowchart(s) generated from this file: 

23# * ``InferenceManager.deploy`` -> ``diagrams/code_diagrams/cli/inference.InferenceManager_deploy.html`` 

24# (PNG: ``diagrams/code_diagrams/cli/inference.InferenceManager_deploy.png``) 

25# * ``InferenceManager.canary_deploy`` -> ``diagrams/code_diagrams/cli/inference.InferenceManager_canary_deploy.html`` 

26# (PNG: ``diagrams/code_diagrams/cli/inference.InferenceManager_canary_deploy.png``) 

27# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

28# <pyflowchart-code-diagram> END 

29 

30 

31if TYPE_CHECKING: 

32 from gco.services.inference_store import InferenceEndpointStore 

33 

34logger = logging.getLogger(__name__) 

35 

36 

37# --------------------------------------------------------------------------- 

38# Mooncake topology — optional endpoint-spec extension 

39# --------------------------------------------------------------------------- 

40# 

41# An endpoint spec may carry an optional ``mooncake`` block describing 

42# disaggregated prefill/decode (PD) serving and/or a shared KV-cache store. 

43# The block is entirely additive: when it is absent the endpoint reconciles 

44# exactly as it does today — one Deployment and one internal ClusterIP Service 

45# behind the shared authenticated inference route. 

46# 

47# The definitions below describe the shape of that block (the dict written to 

48# DynamoDB and read back by the per-region monitor) and the constant 

49# vocabularies its enumerated fields draw from. Byte-size fields are authored 

50# as base-10 integer decimal strings (see :func:`author_byte_size`) so they 

51# round-trip through DynamoDB without being coerced to ``Decimal`` via a float 

52# literal. 

53 

54#: Serving modes a ``mooncake`` block may declare. 

55#: ``disaggregated`` splits prefill and decode; ``store`` runs a single 

56#: KV-store instance; ``both`` composes the two. 

57MOONCAKE_MODES: frozenset[str] = frozenset({"disaggregated", "store", "both"}) 

58 

59#: KV transfer / store intents. ``rdma`` is the default high-performance 

60#: intent: GCO schedules the pod on EFA and renders vLLM's point-to-point 

61#: ``mooncake_protocol`` as ``efa``. ``tcp`` is the non-EFA fallback. 

62MOONCAKE_TRANSFER_PROTOCOLS: frozenset[str] = frozenset({"rdma", "tcp"}) 

63 

64#: KV-store offload tiers for spilling cache beyond GPU memory. 

65MOONCAKE_OFFLOAD_TIERS: frozenset[str] = frozenset({"cpu", "disk", "none"}) 

66 

67#: PD proxy request-scheduling strategies supported today. 

68MOONCAKE_PROXY_SCHEDULING: frozenset[str] = frozenset({"round_robin"}) 

69 

70#: Inclusive bounds for per-role replica counts in an XpYd topology. 

71MOONCAKE_TOPOLOGY_MIN: int = 1 

72MOONCAKE_TOPOLOGY_MAX: int = 1000 

73 

74#: Inclusive bounds for byte-size fields. The ceiling is the signed 64-bit 

75#: maximum; authoring sizes as decimal strings in ``[MIN, MAX]`` keeps them out 

76#: of float/Decimal coercion when they round-trip through DynamoDB. 

77MOONCAKE_BYTE_SIZE_MIN: int = 0 

78MOONCAKE_BYTE_SIZE_MAX: int = 9223372036854775807 

79 

80#: Transfer-engine defaults mirroring Mooncake's reference configuration. 

81MOONCAKE_DEFAULT_BOOTSTRAP_BASE_PORT: int = 8998 

82MOONCAKE_DEFAULT_NUM_WORKERS: int = 10 

83MOONCAKE_DEFAULT_ABORT_REQUEST_TIMEOUT: int = 480 

84 

85 

86class MooncakeTopology(TypedDict): 

87 """An XpYd topology: ``prefill`` (X) and ``decode`` (Y) instance counts.""" 

88 

89 prefill: int 

90 decode: int 

91 

92 

93class MooncakeStoreConfig(TypedDict, total=False): 

94 """KV-cache store pool configuration. 

95 

96 ``global_segment_size`` and ``local_buffer_size`` are byte counts authored 

97 as base-10 integer decimal strings. ``cold_tier_enabled`` opts this 

98 endpoint into the asynchronous, per-region object-store cold tier; the 

99 cold-tier bucket is resolved by the monitor from regional configuration and 

100 is never a user-typed URI. 

101 """ 

102 

103 enabled: bool 

104 metadata_server: str 

105 master_server_address: str 

106 protocol: Literal["rdma", "tcp"] 

107 device_name: str 

108 global_segment_size: str 

109 local_buffer_size: str 

110 offload: Literal["cpu", "disk", "none"] 

111 cold_tier_enabled: bool 

112 

113 

114class MooncakeTransferConfig(TypedDict, total=False): 

115 """RDMA/TCP transfer-engine configuration for KV cache movement.""" 

116 

117 protocol: Literal["rdma", "tcp"] 

118 device_name: str 

119 num_workers: int 

120 bootstrap_base_port: int 

121 abort_request_timeout: int 

122 

123 

124class MooncakeProxyConfig(TypedDict, total=False): 

125 """PD proxy configuration. ``admin_api_key_secret`` names the Kubernetes 

126 Secret holding the proxy admin key; the key value is never carried on the 

127 endpoint spec.""" 

128 

129 image: str 

130 scheduling: Literal["round_robin"] 

131 admin_api_key_secret: str 

132 

133 

134class MooncakeRoleAutoscaling(TypedDict, total=False): 

135 """Per-role autoscaling bounds and metrics for one of prefill/decode.""" 

136 

137 min_replicas: int 

138 max_replicas: int 

139 metrics: list[dict[str, Any]] 

140 

141 

142class MooncakeAutoscalingConfig(TypedDict, total=False): 

143 """Optional per-role pod autoscaling. When absent the topology is static.""" 

144 

145 enabled: bool 

146 prefill: MooncakeRoleAutoscaling 

147 decode: MooncakeRoleAutoscaling 

148 

149 

150class MooncakeSpec(TypedDict, total=False): 

151 """The optional ``mooncake`` block carried on an endpoint spec dict.""" 

152 

153 mode: Literal["disaggregated", "store", "both"] 

154 topology: MooncakeTopology 

155 store: MooncakeStoreConfig 

156 transfer: MooncakeTransferConfig 

157 proxy: MooncakeProxyConfig 

158 autoscaling: MooncakeAutoscalingConfig 

159 

160 

161def author_byte_size(value: int | str) -> str: 

162 """Render a byte-size value as a canonical base-10 integer decimal string. 

163 

164 Mooncake store/transfer sizes (segment size, local buffer) are carried on 

165 the endpoint spec as digit-only strings so they survive the DynamoDB 

166 round-trip without being coerced to ``Decimal`` through a float literal. 

167 

168 Accepts a non-negative ``int`` or a string of base-10 ASCII digits and 

169 returns the same whole number as ``str``. The value must fall in 

170 ``[MOONCAKE_BYTE_SIZE_MIN, MOONCAKE_BYTE_SIZE_MAX]``. Signs, decimal 

171 points, exponents, floats, booleans, and any non-digit text are not 

172 accepted. 

173 

174 Raises: 

175 ValueError: when ``value`` cannot be authored as an in-range base-10 

176 integer. 

177 """ 

178 # ``bool`` is a subclass of ``int``; reject it explicitly so ``True``/``False`` 

179 # never masquerade as 1/0 byte sizes. 

180 if isinstance(value, bool): 

181 raise ValueError(f"byte-size value must be an integer, got bool: {value!r}") 

182 

183 if isinstance(value, int): 

184 size = value 

185 elif isinstance(value, str): 

186 text = value.strip() 

187 if not text or any(ch not in "0123456789" for ch in text): 

188 raise ValueError( 

189 "byte-size value must be a base-10 integer string " 

190 f"(ASCII digits only, no sign, point, or exponent), got {value!r}" 

191 ) 

192 size = int(text) 

193 else: 

194 raise ValueError( 

195 f"byte-size value must be an int or a base-10 digit string, got {type(value).__name__}" 

196 ) 

197 

198 if not MOONCAKE_BYTE_SIZE_MIN <= size <= MOONCAKE_BYTE_SIZE_MAX: 

199 raise ValueError( 

200 "byte-size value out of range " 

201 f"[{MOONCAKE_BYTE_SIZE_MIN}, {MOONCAKE_BYTE_SIZE_MAX}]: {size}" 

202 ) 

203 

204 return str(size) 

205 

206 

207#: Byte-size fields a ``mooncake`` store block may carry. Each is authored as a 

208#: base-10 integer decimal string via :func:`author_byte_size`. 

209_MOONCAKE_STORE_BYTE_SIZE_FIELDS: tuple[str, ...] = ( 

210 "global_segment_size", 

211 "local_buffer_size", 

212) 

213 

214#: Modes that run a split prefill/decode topology and therefore require a 

215#: valid ``topology`` and may carry per-role autoscaling. 

216_MOONCAKE_DISAGGREGATED_MODES: frozenset[str] = frozenset({"disaggregated", "both"}) 

217 

218 

219def _is_plain_int(value: Any) -> TypeGuard[int]: 

220 """True when ``value`` is an ``int`` and not a ``bool``. 

221 

222 ``bool`` is a subclass of ``int``; counts and replica bounds must be real 

223 integers, so ``True``/``False`` are not accepted as 1/0. 

224 """ 

225 return isinstance(value, int) and not isinstance(value, bool) 

226 

227 

228def _validate_role_autoscaling_bounds(role: str, role_block: dict[str, Any]) -> None: 

229 """Validate one role's ``min_replicas``/``max_replicas`` bounds. 

230 

231 Raises :class:`ValueError` naming the violated bound. ``min_replicas`` must 

232 be an integer ``>= 1`` and ``max_replicas`` an integer no smaller than the 

233 effective minimum (which defaults to 1 when ``min_replicas`` is absent). 

234 """ 

235 min_replicas = role_block.get("min_replicas") 

236 max_replicas = role_block.get("max_replicas") 

237 

238 if min_replicas is not None: 

239 if not _is_plain_int(min_replicas): 

240 raise ValueError( 

241 f"mooncake.autoscaling.{role}.min_replicas must be an integer, got {min_replicas!r}" 

242 ) 

243 if min_replicas < 1: 

244 raise ValueError( 

245 f"mooncake.autoscaling.{role}.min_replicas must be >= 1, got {min_replicas}" 

246 ) 

247 

248 if max_replicas is not None: 

249 if not _is_plain_int(max_replicas): 

250 raise ValueError( 

251 f"mooncake.autoscaling.{role}.max_replicas must be an integer, got {max_replicas!r}" 

252 ) 

253 effective_min = min_replicas if _is_plain_int(min_replicas) else 1 

254 if max_replicas < effective_min: 

255 raise ValueError( 

256 f"mooncake.autoscaling.{role}.max_replicas ({max_replicas}) " 

257 f"must be >= min_replicas ({effective_min})" 

258 ) 

259 

260 

261def validate_mooncake_spec(mooncake: dict[str, Any]) -> None: 

262 """Validate a ``mooncake`` endpoint-spec block, failing fast. 

263 

264 Raises :class:`ValueError` on the first rejected field, naming the 

265 offending field so the caller can correct it. The check is pure — it reads 

266 nothing and writes nothing — so a caller that validates before persisting 

267 leaves any previously stored spec untouched when a block is rejected. 

268 

269 The rules enforced here are: 

270 

271 * ``mode`` must be one of the supported serving modes 

272 (:data:`MOONCAKE_MODES`). 

273 * ``transfer`` must be a mapping when present; ``protocol`` must be one of 

274 :data:`MOONCAKE_TRANSFER_PROTOCOLS` and ``device_name`` must be a string 

275 (the empty string requests automatic interface detection). 

276 * Store byte-size fields must author as in-range base-10 integers. 

277 * ``disaggregated``/``both`` modes require integer ``topology.prefill`` and 

278 ``topology.decode`` in 

279 ``[MOONCAKE_TOPOLOGY_MIN, MOONCAKE_TOPOLOGY_MAX]``. 

280 * ``store.cold_tier_enabled`` may be true only while ``store.enabled`` is 

281 true (the cold tier extends the hot store). 

282 * Autoscaling may be enabled only for ``disaggregated``/``both`` modes, and 

283 each present role's ``min_replicas``/``max_replicas`` must satisfy 

284 ``min_replicas >= 1`` and ``max_replicas >= min_replicas``. 

285 """ 

286 if not isinstance(mooncake, dict): 

287 raise ValueError("mooncake block must be a mapping") 

288 

289 mode = mooncake.get("mode") 

290 if mode not in MOONCAKE_MODES: 

291 allowed = ", ".join(sorted(MOONCAKE_MODES)) 

292 raise ValueError(f"mooncake.mode must be one of {{{allowed}}}, got {mode!r}") 

293 

294 store = mooncake.get("store") 

295 if store is not None and not isinstance(store, dict): 

296 raise ValueError("mooncake.store must be a mapping") 

297 

298 transfer = mooncake.get("transfer") 

299 if transfer is not None and not isinstance(transfer, dict): 

300 raise ValueError("mooncake.transfer must be a mapping") 

301 if isinstance(transfer, dict): 

302 protocol = transfer.get("protocol", "rdma") 

303 if protocol not in MOONCAKE_TRANSFER_PROTOCOLS: 

304 allowed = ", ".join(sorted(MOONCAKE_TRANSFER_PROTOCOLS)) 

305 raise ValueError( 

306 f"mooncake.transfer.protocol must be one of {{{allowed}}}, got {protocol!r}" 

307 ) 

308 device_name = transfer.get("device_name", "") 

309 if not isinstance(device_name, str): 

310 raise ValueError(f"mooncake.transfer.device_name must be a string, got {device_name!r}") 

311 

312 # Byte-size fields must author cleanly; surface the offending field name. 

313 if isinstance(store, dict): 

314 for field in _MOONCAKE_STORE_BYTE_SIZE_FIELDS: 

315 if field in store: 

316 try: 

317 author_byte_size(store[field]) 

318 except ValueError as exc: 

319 raise ValueError(f"mooncake.store.{field}: {exc}") from exc 

320 

321 # Split topologies need integer prefill/decode counts in range. 

322 if mode in _MOONCAKE_DISAGGREGATED_MODES: 

323 topology = mooncake.get("topology") 

324 if not isinstance(topology, dict): 

325 raise ValueError( 

326 f"mooncake.topology is required for mode {mode!r} with integer " 

327 "'prefill' and 'decode' counts" 

328 ) 

329 for field in ("prefill", "decode"): 

330 count = topology.get(field) 

331 if not _is_plain_int(count): 

332 raise ValueError( 

333 f"mooncake.topology.{field} must be an integer in " 

334 f"[{MOONCAKE_TOPOLOGY_MIN}, {MOONCAKE_TOPOLOGY_MAX}], " 

335 f"got {count!r}" 

336 ) 

337 if not MOONCAKE_TOPOLOGY_MIN <= count <= MOONCAKE_TOPOLOGY_MAX: 

338 raise ValueError( 

339 f"mooncake.topology.{field} out of range " 

340 f"[{MOONCAKE_TOPOLOGY_MIN}, {MOONCAKE_TOPOLOGY_MAX}]: {count}" 

341 ) 

342 

343 # The cold tier extends the hot store; it cannot be enabled on its own. 

344 if ( 

345 isinstance(store, dict) 

346 and store.get("cold_tier_enabled") is True 

347 and store.get("enabled") is not True 

348 ): 

349 raise ValueError( 

350 "mooncake.store.cold_tier_enabled requires mooncake.store.enabled to be true" 

351 ) 

352 

353 autoscaling = mooncake.get("autoscaling") 

354 if autoscaling is not None: 

355 if not isinstance(autoscaling, dict): 

356 raise ValueError("mooncake.autoscaling must be a mapping") 

357 if autoscaling.get("enabled") is True and mode not in _MOONCAKE_DISAGGREGATED_MODES: 

358 raise ValueError( 

359 "mooncake.autoscaling.enabled requires a 'disaggregated' or " 

360 f"'both' mode, got {mode!r}" 

361 ) 

362 for role in ("prefill", "decode"): 

363 role_block = autoscaling.get(role) 

364 if role_block is None: 

365 continue 

366 if not isinstance(role_block, dict): 

367 raise ValueError(f"mooncake.autoscaling.{role} must be a mapping") 

368 _validate_role_autoscaling_bounds(role, role_block) 

369 

370 

371class InferenceManager: 

372 """Manages inference endpoints via the DynamoDB store.""" 

373 

374 def __init__(self, config: GCOConfig | None = None): 

375 self.config = config or get_config() 

376 self._aws_client = get_aws_client(config) 

377 

378 def _get_store(self, region: str | None = None) -> InferenceEndpointStore: 

379 """Get an InferenceEndpointStore for the global region.""" 

380 from gco.services.inference_store import InferenceEndpointStore 

381 

382 # Use the global region for DynamoDB (same as job store) 

383 store_region = region or self.config.global_region 

384 return InferenceEndpointStore(region=store_region) 

385 

386 def _build_mooncake_block( 

387 self, 

388 *, 

389 mode: str, 

390 prefill_replicas: int, 

391 decode_replicas: int, 

392 store: dict[str, Any] | None, 

393 transfer: dict[str, Any] | None, 

394 proxy: dict[str, Any] | None, 

395 autoscaling: dict[str, Any] | None, 

396 default_proxy_image: str | None = None, 

397 ) -> dict[str, Any]: 

398 """Assemble and validate an optional ``spec.mooncake`` block. 

399 

400 Composes the topology and any supplied store/transfer/proxy/autoscaling 

401 sub-blocks into a single mapping, authoring store byte-size fields as 

402 base-10 integer decimal strings so they round-trip through DynamoDB, 

403 then validates the result. Validation is pure and runs before the 

404 caller persists anything, so a rejected block leaves any previously 

405 stored spec untouched. Raises :class:`ValueError` — naming the offending 

406 field — when the mode is unsupported or any field is invalid. 

407 

408 The store-bearing modes (``store`` and ``both``) default the store to 

409 enabled so the shared master address is wired in (the ``both``-mode 

410 MultiConnector's store half depends on it), and split modes 

411 (``disaggregated`` and ``both``) default the prefill-decode proxy image 

412 to ``default_proxy_image`` when the caller supplies no explicit proxy 

413 image. 

414 """ 

415 block: dict[str, Any] = {"mode": mode} 

416 

417 # Split modes carry an XpYd topology; a single-instance store does not. 

418 if mode in _MOONCAKE_DISAGGREGATED_MODES: 

419 block["topology"] = { 

420 "prefill": prefill_replicas, 

421 "decode": decode_replicas, 

422 } 

423 

424 # The store-bearing modes (store and both) only function with the KV 

425 # store enabled: the both-mode MultiConnector's store half is wired to 

426 # the shared master address, which the monitor renders only for an 

427 # enabled store. So a store block is always present for those modes, 

428 # defaulting enabled to True; an explicit store block still tunes 

429 # offload, sizes, and the cold tier. 

430 store_block = dict(store) if store is not None else None 

431 if mode in ("store", "both"): 

432 store_block = dict(store_block or {}) 

433 store_block.setdefault("enabled", True) 

434 if store_block is not None: 

435 # Author byte-size fields as canonical decimal strings up front so 

436 # the persisted spec round-trips through DynamoDB without float or 

437 # Decimal coercion. Authoring also fails fast on bad inputs. 

438 for field in _MOONCAKE_STORE_BYTE_SIZE_FIELDS: 

439 if field in store_block: 

440 try: 

441 store_block[field] = author_byte_size(store_block[field]) 

442 except ValueError as exc: 

443 raise ValueError(f"mooncake.store.{field}: {exc}") from exc 

444 block["store"] = store_block 

445 

446 if transfer is not None: 

447 block["transfer"] = dict(transfer) 

448 

449 # Split modes are fronted by the prefill-decode proxy, which needs a 

450 # container image. Default it to the same image the role pods serve from 

451 # (the upstream vLLM image bundles the reference proxy) so a split deploy 

452 # stands up without a separate proxy image; an explicit proxy image 

453 # still wins. 

454 proxy_block = dict(proxy) if proxy is not None else None 

455 if mode in _MOONCAKE_DISAGGREGATED_MODES and default_proxy_image: 

456 proxy_block = dict(proxy_block or {}) 

457 proxy_block.setdefault("image", default_proxy_image) 

458 if proxy_block is not None: 

459 block["proxy"] = proxy_block 

460 

461 if autoscaling is not None: 

462 block["autoscaling"] = dict(autoscaling) 

463 

464 # Fail fast before persisting: rejects unsupported modes (naming the 

465 # allowed values) and every other invalid field. 

466 validate_mooncake_spec(block) 

467 return block 

468 

469 def deploy( 

470 self, 

471 endpoint_name: str, 

472 image: str | None = None, 

473 target_regions: list[str] | None = None, 

474 replicas: int = 1, 

475 gpu_count: int = 1, 

476 gpu_type: str | None = None, 

477 port: int = 8000, 

478 model_path: str | None = None, 

479 model_source: str | None = None, 

480 health_check_path: str = "/health", 

481 env: dict[str, str] | None = None, 

482 namespace: str = "gco-inference", 

483 labels: dict[str, str] | None = None, 

484 autoscaling: dict[str, Any] | None = None, 

485 capacity_type: str | None = None, 

486 extra_args: list[str] | None = None, 

487 accelerator: str = "nvidia", 

488 node_selector: dict[str, str] | None = None, 

489 rewrite_image: bool = True, 

490 *, 

491 framework: str | None = None, 

492 mooncake_mode: str | None = None, 

493 prefill_replicas: int = 1, 

494 decode_replicas: int = 1, 

495 mooncake_store: dict[str, Any] | None = None, 

496 mooncake_transfer: dict[str, Any] | None = None, 

497 mooncake_proxy: dict[str, Any] | None = None, 

498 mooncake_autoscaling: dict[str, Any] | None = None, 

499 ) -> dict[str, Any]: 

500 """ 

501 Deploy an inference endpoint to one or more regions. 

502 

503 The endpoint spec is written to DynamoDB. The inference_monitor 

504 in each target region picks it up and creates the K8s resources. 

505 

506 Args: 

507 endpoint_name: Unique name for the endpoint 

508 image: Container image (e.g. vllm/vllm-openai:v0.29.0). Optional 

509 when ``mooncake_mode`` is set: a disaggregated/store deploy 

510 with no image falls back to the default upstream 

511 Mooncake-enabled vLLM image. A plain deploy still requires an 

512 image. 

513 target_regions: Regions to deploy to (default: all deployed regions) 

514 replicas: Number of replicas per region 

515 gpu_count: GPUs per replica 

516 gpu_type: GPU instance type hint for node selector 

517 port: Container port 

518 model_path: EFS path for model weights 

519 health_check_path: Health check endpoint path 

520 env: Environment variables 

521 namespace: Kubernetes namespace 

522 labels: Labels for the endpoint 

523 rewrite_image: When True (the default), rewrite ECR URIs in 

524 ``image`` to target each region's local replica. Non-ECR 

525 refs (Docker Hub, GHCR, etc.) are left unchanged. When 

526 False, the URI is written verbatim to every region's 

527 spec — the operator is responsible for cross-region 

528 pulls. Per-region rewrites are stored under a 

529 ``region_overrides`` map on the spec keyed by region. 

530 mooncake_mode: When set to one of ``disaggregated``, ``store``, 

531 or ``both``, build and persist a ``spec.mooncake`` block for 

532 disaggregated prefill/decode serving and/or a shared KV-cache 

533 store. An unsupported value is rejected before anything is 

534 persisted. 

535 prefill_replicas: X in an XpYd topology — prefill instance count 

536 for split (``disaggregated``/``both``) modes. 

537 decode_replicas: Y in an XpYd topology — decode instance count for 

538 split modes. 

539 mooncake_store: Optional KV-store pool configuration merged into 

540 ``spec.mooncake.store``. Byte-size fields are authored as 

541 base-10 integer decimal strings so they round-trip through 

542 DynamoDB. 

543 mooncake_transfer: Optional Mooncake transfer intent and network 

544 device. ``protocol`` accepts ``rdma`` (the default; scheduled 

545 on EFA and rendered to vLLM as ``mooncake_protocol=efa``) or 

546 ``tcp`` (no EFA placement). ``device_name`` is forwarded to 

547 both the connector and mounted Mooncake configuration; an 

548 empty string lets Mooncake auto-detect it. 

549 mooncake_proxy: Optional PD proxy configuration merged into 

550 ``spec.mooncake.proxy``. 

551 mooncake_autoscaling: Optional per-role autoscaling configuration 

552 merged into ``spec.mooncake.autoscaling``. 

553 

554 Returns: 

555 Created endpoint record 

556 """ 

557 if framework not in (None, "vllm", "tgi"): 

558 raise ValueError("framework must be 'vllm' or 'tgi'") 

559 if mooncake_mode is not None and framework == "tgi": 

560 raise ValueError("Mooncake serving requires the vllm framework") 

561 if mooncake_mode is not None and framework is None: 

562 framework = "vllm" 

563 

564 # Build the optional mooncake block first and validate it before any 

565 # persistence so a rejected block leaves any stored spec untouched. A 

566 # disaggregated/store deploy without an explicit image falls back to 

567 # the default upstream Mooncake-enabled vLLM image. 

568 mooncake_block: dict[str, Any] | None = None 

569 if mooncake_mode is not None: 

570 # Resolve the image before building the block so a split mode's 

571 # prefill-decode proxy can default to the same image the role pods 

572 # serve from (the upstream vLLM image bundles the reference proxy). 

573 if image is None: 

574 from .images import default_disaggregated_image 

575 

576 image = default_disaggregated_image(config=self.config) 

577 mooncake_block = self._build_mooncake_block( 

578 mode=mooncake_mode, 

579 prefill_replicas=prefill_replicas, 

580 decode_replicas=decode_replicas, 

581 store=mooncake_store, 

582 transfer=mooncake_transfer, 

583 proxy=mooncake_proxy, 

584 autoscaling=mooncake_autoscaling, 

585 default_proxy_image=image, 

586 ) 

587 

588 if image is None: 

589 raise ValueError( 

590 "an image is required (pass image, or set mooncake_mode to use " 

591 "the default upstream Mooncake-enabled vLLM image)" 

592 ) 

593 

594 if not target_regions: 

595 stacks = self._aws_client.discover_regional_stacks() 

596 target_regions = list(stacks.keys()) 

597 if not target_regions: 

598 raise ValueError("No deployed regions found. Deploy infrastructure first.") 

599 

600 # Per-region image-URI rewrites for ECR refs. Each target region 

601 # gets the local replica's URI on its own spec, so the 

602 # inference_monitor's pod-spec materialiser pulls in-region 

603 # rather than across the WAN. Non-ECR URIs come back unchanged 

604 # from the helper, so this is a no-op for Docker Hub / GHCR refs. 

605 # 

606 # The helper lives in ``cli._image_uri`` rather than ``cli.images`` 

607 # so this import doesn't create a module-level cycle: 

608 # ``cli.images`` itself imports the same helper. ``cli._image_uri`` 

609 # is a leaf module with no project-side dependencies. 

610 region_image_map: dict[str, str] = {} 

611 if rewrite_image: 

612 from ._image_uri import rewrite_image_uri_for_region 

613 

614 for region in target_regions: 

615 region_image_map[region] = rewrite_image_uri_for_region(image, region) 

616 

617 spec = { 

618 "image": image, 

619 "port": port, 

620 "replicas": replicas, 

621 "gpu_count": gpu_count, 

622 "health_check_path": health_check_path, 

623 } 

624 if framework: 

625 spec["framework"] = framework 

626 # Preserve the rewrite map on the spec so the inference_monitor 

627 # service can pick the right URI per region when materialising 

628 # pods. When ``rewrite_image=False`` no map is set and the flat 

629 # ``image`` field is the only source. 

630 if region_image_map and any(uri != image for uri in region_image_map.values()): 

631 spec["region_image_uris"] = region_image_map 

632 if gpu_type: 

633 spec["gpu_type"] = gpu_type 

634 if model_path: 

635 spec["model_path"] = model_path 

636 if model_source: 

637 spec["model_source"] = model_source 

638 if env: 

639 spec["env"] = env 

640 if autoscaling: 

641 spec["autoscaling"] = autoscaling 

642 if capacity_type: 

643 spec["capacity_type"] = capacity_type 

644 if extra_args: 

645 spec["args"] = extra_args 

646 if accelerator != "nvidia": 

647 spec["accelerator"] = accelerator 

648 if node_selector: 

649 spec["node_selector"] = node_selector 

650 if mooncake_block is not None: 

651 spec["mooncake"] = mooncake_block 

652 

653 store = self._get_store() 

654 result: dict[str, Any] = store.create_endpoint( 

655 endpoint_name=endpoint_name, 

656 spec=spec, 

657 target_regions=target_regions, 

658 namespace=namespace, 

659 labels=labels, 

660 ) 

661 return result 

662 

663 def list_endpoints( 

664 self, 

665 desired_state: str | None = None, 

666 region: str | None = None, 

667 ) -> list[dict[str, Any]]: 

668 """List all inference endpoints.""" 

669 store = self._get_store() 

670 result: list[dict[str, Any]] = store.list_endpoints( 

671 desired_state=desired_state, 

672 target_region=region, 

673 ) 

674 return result 

675 

676 def get_endpoint(self, endpoint_name: str) -> dict[str, Any] | None: 

677 """Get details of a specific endpoint.""" 

678 store = self._get_store() 

679 result: dict[str, Any] | None = store.get_endpoint(endpoint_name) 

680 return result 

681 

682 @staticmethod 

683 def _load_mutable_endpoint( 

684 store: InferenceEndpointStore, 

685 endpoint_name: str, 

686 operation: str, 

687 ) -> tuple[dict[str, Any], str, str | None] | None: 

688 """Read and lifecycle-fence an endpoint before an ordinary mutation.""" 

689 endpoint = store.get_endpoint(endpoint_name, consistent_read=True) 

690 if endpoint is None: 

691 return None 

692 if endpoint.get("desired_state") == "deleted": 

693 raise ValueError( 

694 f"Endpoint '{endpoint_name}' is deleted and cannot be {operation}; " 

695 "redeploy it after deletion completes." 

696 ) 

697 lifecycle_id = endpoint.get("lifecycle_id") 

698 if not isinstance(lifecycle_id, str) or not lifecycle_id: 

699 migrated = store.ensure_lifecycle_metadata(endpoint) 

700 if migrated is None: 

701 raise ValueError( 

702 f"Endpoint '{endpoint_name}' changed while initializing lifecycle identity" 

703 ) 

704 endpoint = migrated 

705 lifecycle_id = endpoint.get("lifecycle_id") 

706 if not isinstance(lifecycle_id, str) or not lifecycle_id: 

707 raise ValueError(f"Endpoint '{endpoint_name}' has no lifecycle identity") 

708 updated_at = endpoint.get("updated_at") 

709 return endpoint, lifecycle_id, updated_at if isinstance(updated_at, str) else None 

710 

711 @staticmethod 

712 def _raise_write_conflict(endpoint_name: str, operation: str) -> None: 

713 raise ValueError( 

714 f"Endpoint '{endpoint_name}' changed while being {operation}; retry after reading status" 

715 ) 

716 

717 def scale(self, endpoint_name: str, replicas: int) -> dict[str, Any] | None: 

718 """Scale a classic static endpoint only.""" 

719 store = self._get_store() 

720 loaded = self._load_mutable_endpoint(store, endpoint_name, "scaled") 

721 if loaded is None: 

722 return None 

723 endpoint, lifecycle_id, _updated_at = loaded 

724 spec = endpoint.get("spec") 

725 if isinstance(spec, dict) and "mooncake" in spec: 

726 raise ValueError( 

727 f"Endpoint '{endpoint_name}' uses Mooncake topology; use " 

728 "'gco inference set-topology' instead of 'gco inference scale'." 

729 ) 

730 autoscaling = spec.get("autoscaling") if isinstance(spec, dict) else None 

731 if isinstance(autoscaling, dict) and autoscaling.get("enabled") is True: 

732 raise ValueError( 

733 f"Endpoint '{endpoint_name}' is autoscaled; update its min/max autoscaling " 

734 "bounds or disable autoscaling before using 'gco inference scale'." 

735 ) 

736 result: dict[str, Any] | None = store.scale_endpoint( 

737 endpoint_name, 

738 replicas, 

739 expected_lifecycle_id=lifecycle_id, 

740 ) 

741 if result is None: 

742 self._raise_write_conflict(endpoint_name, "scaled") 

743 return result 

744 

745 def set_topology( 

746 self, 

747 endpoint_name: str, 

748 prefill: int, 

749 decode: int, 

750 ) -> dict[str, Any] | None: 

751 """Resize a disaggregated endpoint's prefill/decode topology. 

752 

753 Updates ``spec.mooncake.topology`` to the new XpYd counts and 

754 re-triggers reconciliation (via :meth:`InferenceEndpointStore.update_spec`, 

755 which flips ``desired_state`` to ``deploying``) so the per-region 

756 monitor adjusts the prefill and decode role replica counts. 

757 

758 Both counts must be integers in the inclusive range 

759 ``[MOONCAKE_TOPOLOGY_MIN, MOONCAKE_TOPOLOGY_MAX]``. The counts are 

760 validated before anything is read or written, so a rejected request 

761 names the offending count and leaves the stored topology and 

762 ``desired_state`` untouched. 

763 

764 Args: 

765 endpoint_name: Name of the disaggregated endpoint to resize. 

766 prefill: New prefill (X) instance count. 

767 decode: New decode (Y) instance count. 

768 

769 Returns: 

770 The updated endpoint record, or ``None`` when no endpoint with 

771 ``endpoint_name`` exists. 

772 

773 Raises: 

774 ValueError: when ``prefill`` or ``decode`` is not an integer in 

775 ``[MOONCAKE_TOPOLOGY_MIN, MOONCAKE_TOPOLOGY_MAX]``. 

776 """ 

777 # Validate before any read or write so a bad count names the offending 

778 # field and leaves the stored topology and desired_state unchanged. 

779 for field, count in (("prefill", prefill), ("decode", decode)): 

780 if not _is_plain_int(count): 

781 raise ValueError( 

782 f"topology {field} count must be an integer in " 

783 f"[{MOONCAKE_TOPOLOGY_MIN}, {MOONCAKE_TOPOLOGY_MAX}], " 

784 f"got {count!r}" 

785 ) 

786 if not MOONCAKE_TOPOLOGY_MIN <= count <= MOONCAKE_TOPOLOGY_MAX: 

787 raise ValueError( 

788 f"topology {field} count out of range " 

789 f"[{MOONCAKE_TOPOLOGY_MIN}, {MOONCAKE_TOPOLOGY_MAX}]: {count}" 

790 ) 

791 

792 store = self._get_store() 

793 loaded = self._load_mutable_endpoint(store, endpoint_name, "updated") 

794 if loaded is None: 

795 return None 

796 endpoint, lifecycle_id, updated_at = loaded 

797 

798 raw_spec = endpoint.get("spec") 

799 if not isinstance(raw_spec, dict): 

800 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec") 

801 spec = deepcopy(raw_spec) 

802 # Preserve any existing mooncake sub-fields and replace only the 

803 # topology counts. 

804 mooncake = dict(spec.get("mooncake") or {}) 

805 mooncake["topology"] = {"prefill": prefill, "decode": decode} 

806 spec["mooncake"] = mooncake 

807 

808 result: dict[str, Any] | None = store.update_spec( 

809 endpoint_name, 

810 spec, 

811 expected_lifecycle_id=lifecycle_id, 

812 expected_updated_at=updated_at, 

813 ) 

814 if result is None: 

815 self._raise_write_conflict(endpoint_name, "updated") 

816 return result 

817 

818 def configure_store( 

819 self, 

820 endpoint_name: str, 

821 store_config: dict[str, Any], 

822 ) -> dict[str, Any] | None: 

823 """Update an endpoint's KV-cache store configuration. 

824 

825 Merges ``store_config`` into ``spec.mooncake.store`` and re-triggers 

826 reconciliation (via :meth:`InferenceEndpointStore.update_spec`, which 

827 flips ``desired_state`` to ``deploying``) so the per-region monitor 

828 picks up the new store settings. 

829 

830 Store byte-size fields are authored as base-10 integer decimal strings 

831 (so they round-trip through DynamoDB without float/Decimal coercion) 

832 and the resulting ``mooncake`` block is validated before anything is 

833 written. A rejected configuration names the offending field and leaves 

834 the stored spec untouched. 

835 

836 Args: 

837 endpoint_name: Name of the endpoint to reconfigure. 

838 store_config: KV-store pool settings merged into 

839 ``spec.mooncake.store``. 

840 

841 Returns: 

842 The updated endpoint record, or ``None`` when no endpoint with 

843 ``endpoint_name`` exists. 

844 

845 Raises: 

846 ValueError: when the resulting ``mooncake`` block is invalid (for 

847 example an out-of-range byte-size field). 

848 """ 

849 store = self._get_store() 

850 loaded = self._load_mutable_endpoint(store, endpoint_name, "configured") 

851 if loaded is None: 

852 return None 

853 endpoint, lifecycle_id, updated_at = loaded 

854 

855 raw_spec = endpoint.get("spec") 

856 if not isinstance(raw_spec, dict): 

857 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec") 

858 spec = deepcopy(raw_spec) 

859 # Preserve any existing mooncake sub-fields and replace only the store 

860 # block, authoring byte-size fields as canonical decimal strings. 

861 mooncake = dict(spec.get("mooncake") or {}) 

862 store_block = dict(store_config) 

863 for field in _MOONCAKE_STORE_BYTE_SIZE_FIELDS: 

864 if field in store_block: 

865 try: 

866 store_block[field] = author_byte_size(store_block[field]) 

867 except ValueError as exc: 

868 raise ValueError(f"mooncake.store.{field}: {exc}") from exc 

869 mooncake["store"] = store_block 

870 spec["mooncake"] = mooncake 

871 

872 # Fail fast before persisting so a rejected block leaves the stored 

873 # spec untouched. 

874 validate_mooncake_spec(mooncake) 

875 

876 result: dict[str, Any] | None = store.update_spec( 

877 endpoint_name, 

878 spec, 

879 expected_lifecycle_id=lifecycle_id, 

880 expected_updated_at=updated_at, 

881 ) 

882 if result is None: 

883 self._raise_write_conflict(endpoint_name, "configured") 

884 return result 

885 

886 def stop(self, endpoint_name: str) -> dict[str, Any] | None: 

887 """Stop a live endpoint without crossing a deletion boundary.""" 

888 store = self._get_store() 

889 loaded = self._load_mutable_endpoint(store, endpoint_name, "stopped") 

890 if loaded is None: 

891 return None 

892 _endpoint, lifecycle_id, _updated_at = loaded 

893 result: dict[str, Any] | None = store.update_desired_state( 

894 endpoint_name, 

895 "stopped", 

896 expected_lifecycle_id=lifecycle_id, 

897 ) 

898 if result is None: 

899 self._raise_write_conflict(endpoint_name, "stopped") 

900 return result 

901 

902 def start(self, endpoint_name: str) -> dict[str, Any] | None: 

903 """Start a stopped endpoint; deleted endpoints must be redeployed.""" 

904 store = self._get_store() 

905 result: dict[str, Any] | None = store.start_endpoint(endpoint_name) 

906 return result 

907 

908 def delete( 

909 self, 

910 endpoint_name: str, 

911 *, 

912 expected_owner_label: tuple[str, str] | None = None, 

913 expected_lifecycle_id: str | None = None, 

914 ) -> dict[str, Any] | None: 

915 """Mark an endpoint deleted with optional exact-incarnation ownership.""" 

916 if expected_owner_label is not None and not expected_lifecycle_id: 

917 raise ValueError("Owner-conditioned deletion also requires an immutable lifecycle id") 

918 store = self._get_store() 

919 if expected_owner_label is None: 

920 endpoint = store.get_endpoint(endpoint_name, consistent_read=True) 

921 if endpoint is None: 

922 return None 

923 migrated = store.ensure_lifecycle_metadata(endpoint) 

924 if migrated is None: 

925 raise ValueError( 

926 f"Endpoint '{endpoint_name}' changed while initializing deletion identity" 

927 ) 

928 lifecycle_id = migrated.get("lifecycle_id") 

929 if not isinstance(lifecycle_id, str) or not lifecycle_id: 

930 raise ValueError(f"Endpoint '{endpoint_name}' has no lifecycle identity") 

931 result: dict[str, Any] | None = store.update_desired_state( 

932 endpoint_name, 

933 "deleted", 

934 expected_lifecycle_id=lifecycle_id, 

935 ) 

936 else: 

937 result = store.update_desired_state( 

938 endpoint_name, 

939 "deleted", 

940 expected_label=expected_owner_label, 

941 expected_lifecycle_id=expected_lifecycle_id, 

942 ) 

943 return result 

944 

945 def update_image(self, endpoint_name: str, image: str) -> dict[str, Any] | None: 

946 """Update the container image for an endpoint.""" 

947 if not isinstance(image, str) or not image.strip(): 

948 raise ValueError("Image must be a non-empty string") 

949 

950 store = self._get_store() 

951 loaded = self._load_mutable_endpoint(store, endpoint_name, "updated") 

952 if loaded is None: 

953 return None 

954 endpoint, lifecycle_id, updated_at = loaded 

955 raw_spec = endpoint.get("spec") 

956 if not isinstance(raw_spec, dict): 

957 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec") 

958 spec = deepcopy(raw_spec) 

959 spec["image"] = image.strip() 

960 # A direct image update is global. Stale regional rewrites would take 

961 # precedence in the monitor and silently keep serving the old image. 

962 spec.pop("region_image_uris", None) 

963 result: dict[str, Any] | None = store.update_spec( 

964 endpoint_name, 

965 spec, 

966 expected_lifecycle_id=lifecycle_id, 

967 expected_updated_at=updated_at, 

968 ) 

969 if result is None: 

970 self._raise_write_conflict(endpoint_name, "updated") 

971 return result 

972 

973 def add_region(self, endpoint_name: str, region: str) -> dict[str, Any] | None: 

974 """Add a target and append it to this lifecycle's cleanup history.""" 

975 store = self._get_store() 

976 loaded = self._load_mutable_endpoint(store, endpoint_name, "added to a Region") 

977 if loaded is None: 

978 return None 

979 endpoint, lifecycle_id, updated_at = loaded 

980 if updated_at is None: 

981 raise ValueError(f"Endpoint '{endpoint_name}' has no conditional update timestamp") 

982 regions = list(endpoint.get("target_regions") or []) 

983 cleanup_regions = list(endpoint.get("cleanup_regions") or regions) 

984 if region in regions: 

985 return endpoint 

986 regions.append(region) 

987 if region not in cleanup_regions: 

988 cleanup_regions.append(region) 

989 raw_generations = endpoint.get("region_generations") 

990 stored_generations = raw_generations if isinstance(raw_generations, dict) else {} 

991 region_generations = { 

992 cleanup_region: ( 

993 stored_generations[cleanup_region] 

994 if isinstance(stored_generations.get(cleanup_region), str) 

995 and stored_generations[cleanup_region] 

996 else secrets.token_hex(32) 

997 ) 

998 for cleanup_region in cleanup_regions 

999 } 

1000 # Every target membership transition gets a fresh token, even when the 

1001 # Region appeared earlier in cleanup history. This invalidates a prior 

1002 # terminal removal acknowledgement before resources can be recreated. 

1003 region_generations[region] = secrets.token_hex(32) 

1004 try: 

1005 result = store.update_target_regions( 

1006 endpoint_name, 

1007 regions, 

1008 cleanup_regions, 

1009 region_generations, 

1010 expected_lifecycle_id=lifecycle_id, 

1011 expected_updated_at=updated_at, 

1012 ) 

1013 except Exception as e: 

1014 logger.error("Failed to add region: %s", e) 

1015 return None 

1016 if result is None: 

1017 self._raise_write_conflict(endpoint_name, "added to a Region") 

1018 return result 

1019 

1020 def remove_region(self, endpoint_name: str, region: str) -> dict[str, Any] | None: 

1021 """Remove a target without erasing its authoritative cleanup history.""" 

1022 store = self._get_store() 

1023 loaded = self._load_mutable_endpoint(store, endpoint_name, "removed from a Region") 

1024 if loaded is None: 

1025 return None 

1026 endpoint, lifecycle_id, updated_at = loaded 

1027 if updated_at is None: 

1028 raise ValueError(f"Endpoint '{endpoint_name}' has no conditional update timestamp") 

1029 regions = list(endpoint.get("target_regions") or []) 

1030 cleanup_regions = list(endpoint.get("cleanup_regions") or regions) 

1031 if region not in regions: 

1032 return endpoint 

1033 regions.remove(region) 

1034 if region not in cleanup_regions: 

1035 cleanup_regions.append(region) 

1036 raw_generations = endpoint.get("region_generations") 

1037 stored_generations = raw_generations if isinstance(raw_generations, dict) else {} 

1038 region_generations = { 

1039 cleanup_region: ( 

1040 stored_generations[cleanup_region] 

1041 if isinstance(stored_generations.get(cleanup_region), str) 

1042 and stored_generations[cleanup_region] 

1043 else secrets.token_hex(32) 

1044 ) 

1045 for cleanup_region in cleanup_regions 

1046 } 

1047 # The cleanup pass must acknowledge this removal, not any earlier 

1048 # remove/re-add cycle for the same Region. 

1049 region_generations[region] = secrets.token_hex(32) 

1050 try: 

1051 result = store.update_target_regions( 

1052 endpoint_name, 

1053 regions, 

1054 cleanup_regions, 

1055 region_generations, 

1056 expected_lifecycle_id=lifecycle_id, 

1057 expected_updated_at=updated_at, 

1058 ) 

1059 except Exception as e: 

1060 logger.error("Failed to remove region: %s", e) 

1061 return None 

1062 if result is None: 

1063 self._raise_write_conflict(endpoint_name, "removed from a Region") 

1064 return result 

1065 

1066 def canary_deploy( 

1067 self, 

1068 endpoint_name: str, 

1069 image: str, 

1070 weight: int = 10, 

1071 replicas: int = 1, 

1072 ) -> dict[str, Any] | None: 

1073 """Start a canary deployment for an existing classic endpoint. 

1074 

1075 Creates a canary variant with the new image receiving ``weight``% 

1076 of traffic. Mooncake endpoints are excluded because their split-role 

1077 topology cannot be represented by the classic canary Deployment. 

1078 

1079 Args: 

1080 endpoint_name: Existing endpoint to canary 

1081 image: New container image for the canary 

1082 weight: Percentage of traffic to route to canary (1-99) 

1083 replicas: Positive number of canary replicas 

1084 

1085 Returns: 

1086 Updated endpoint record, or None if endpoint not found 

1087 """ 

1088 if not isinstance(image, str) or not image.strip(): 

1089 raise ValueError("Canary image must be a non-empty string") 

1090 if not _is_plain_int(weight) or not 1 <= weight <= 99: 

1091 raise ValueError("Canary weight must be an integer between 1 and 99") 

1092 if not _is_plain_int(replicas) or replicas < 1: 

1093 raise ValueError("Canary replicas must be a positive integer") 

1094 

1095 store = self._get_store() 

1096 loaded = self._load_mutable_endpoint(store, endpoint_name, "updated") 

1097 if loaded is None: 

1098 return None 

1099 endpoint, lifecycle_id, updated_at = loaded 

1100 

1101 if endpoint.get("desired_state") not in ("running", "deploying"): 

1102 raise ValueError( 

1103 f"Cannot canary an endpoint in '{endpoint.get('desired_state')}' state. " 

1104 "Endpoint must be running or deploying." 

1105 ) 

1106 

1107 raw_spec = endpoint.get("spec") 

1108 if not isinstance(raw_spec, dict): 

1109 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec") 

1110 if "mooncake" in raw_spec: 

1111 raise ValueError("Canary deployments are not supported for Mooncake endpoints") 

1112 

1113 # Never mutate the object returned by the store; callers and test 

1114 # doubles may retain it as shared state. 

1115 spec = deepcopy(raw_spec) 

1116 spec["canary"] = { 

1117 "image": image.strip(), 

1118 "weight": weight, 

1119 "replicas": replicas, 

1120 } 

1121 

1122 result: dict[str, Any] | None = store.update_spec( 

1123 endpoint_name, 

1124 spec, 

1125 expected_lifecycle_id=lifecycle_id, 

1126 expected_updated_at=updated_at, 

1127 ) 

1128 if result is None: 

1129 self._raise_write_conflict(endpoint_name, "updated") 

1130 return result 

1131 

1132 def promote_canary(self, endpoint_name: str) -> dict[str, Any] | None: 

1133 """Promote a classic canary to primary and remove its deployment.""" 

1134 store = self._get_store() 

1135 loaded = self._load_mutable_endpoint(store, endpoint_name, "updated") 

1136 if loaded is None: 

1137 return None 

1138 endpoint, lifecycle_id, updated_at = loaded 

1139 

1140 raw_spec = endpoint.get("spec") 

1141 if not isinstance(raw_spec, dict): 

1142 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec") 

1143 if "mooncake" in raw_spec: 

1144 raise ValueError("Canary promotion is not supported for Mooncake endpoints") 

1145 

1146 canary = raw_spec.get("canary") 

1147 if not isinstance(canary, dict): 

1148 raise ValueError(f"Endpoint '{endpoint_name}' has no active canary deployment") 

1149 if "image" not in canary: 

1150 raise ValueError( 

1151 f"Canary deployment for '{endpoint_name}' is missing the 'image' field" 

1152 ) 

1153 canary_image = canary["image"] 

1154 if not isinstance(canary_image, str) or not canary_image.strip(): 

1155 raise ValueError( 

1156 f"Canary deployment for '{endpoint_name}' has an invalid 'image' field" 

1157 ) 

1158 

1159 spec = deepcopy(raw_spec) 

1160 spec["image"] = canary_image.strip() 

1161 spec.pop("canary", None) 

1162 # The canary image is explicit and global. Existing per-region primary 

1163 # rewrites point at the superseded image and must not take precedence. 

1164 spec.pop("region_image_uris", None) 

1165 

1166 result: dict[str, Any] | None = store.update_spec( 

1167 endpoint_name, 

1168 spec, 

1169 expected_lifecycle_id=lifecycle_id, 

1170 expected_updated_at=updated_at, 

1171 ) 

1172 if result is None: 

1173 self._raise_write_conflict(endpoint_name, "updated") 

1174 return result 

1175 

1176 def rollback_canary(self, endpoint_name: str) -> dict[str, Any] | None: 

1177 """Remove the canary deployment, keeping the primary unchanged.""" 

1178 store = self._get_store() 

1179 loaded = self._load_mutable_endpoint(store, endpoint_name, "updated") 

1180 if loaded is None: 

1181 return None 

1182 endpoint, lifecycle_id, updated_at = loaded 

1183 

1184 raw_spec = endpoint.get("spec") 

1185 if not isinstance(raw_spec, dict): 

1186 raise ValueError(f"Endpoint '{endpoint_name}' has an invalid spec") 

1187 if "canary" not in raw_spec: 

1188 raise ValueError(f"Endpoint '{endpoint_name}' has no active canary deployment") 

1189 

1190 # Rollback is deliberately allowed for a legacy invalid 

1191 # Mooncake-plus-canary record so an operator can repair it. 

1192 spec = deepcopy(raw_spec) 

1193 spec.pop("canary", None) 

1194 result: dict[str, Any] | None = store.update_spec( 

1195 endpoint_name, 

1196 spec, 

1197 expected_lifecycle_id=lifecycle_id, 

1198 expected_updated_at=updated_at, 

1199 ) 

1200 if result is None: 

1201 self._raise_write_conflict(endpoint_name, "updated") 

1202 return result 

1203 

1204 

1205def get_inference_manager(config: GCOConfig | None = None) -> InferenceManager: 

1206 """Factory function for InferenceManager.""" 

1207 return InferenceManager(config)