Coverage for cli / commands / inference_cmd.py: 100.00%

882 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Inference endpoint commands.""" 

2 

3import codecs 

4import json 

5import sys 

6from email.message import Message 

7from typing import Any 

8 

9import click 

10 

11from ..config import GCOConfig 

12from ..output import ( 

13 confirm, 

14 emit_structured_document, 

15 get_output_formatter, 

16 interactive_echo, 

17) 

18 

19pass_config = click.make_pass_decorator(GCOConfig, ensure=True) 

20 

21 

22@click.group() 

23@pass_config 

24def inference(config: Any) -> None: 

25 """Manage multi-region inference endpoints.""" 

26 

27 

28@inference.command("deploy") 

29@click.argument("endpoint_name") 

30@click.option( 

31 "--image", 

32 "-i", 

33 default=None, 

34 help="Container image (e.g. vllm/vllm-openai:v0.32.0). Optional with " 

35 "--mooncake-mode: falls back to the default upstream Mooncake-enabled vLLM image.", 

36) 

37@click.option( 

38 "--framework", 

39 type=click.Choice(["vllm", "sglang"]), 

40 default=None, 

41 help="Explicit serving runtime contract; persisted for renderer/probe behavior. " 

42 "'sglang' renders the official launcher (python3 -m sglang.launch_server) " 

43 "listening on --port, with --model-path taken from -e MODEL=... unless passed " 

44 "via --extra-args, and needs an NVIDIA GPU of compute capability 8.0+ " 

45 "(A10G/L4/L40S/A100/H100): its pods are kept off T4 (g4dn) and older GPUs.", 

46) 

47@click.option( 

48 "--region", 

49 "-r", 

50 multiple=True, 

51 help="Target region(s). Repeatable. Default: all deployed regions", 

52) 

53@click.option("--replicas", default=1, help="Replicas per region (default: 1)") 

54@click.option("--gpu-count", default=1, help="GPUs per replica (default: 1)") 

55@click.option( 

56 "--gpu-type", 

57 help="Preferred instance type (e.g. g5.xlarge). A hint, not a requirement: " 

58 "the pods prefer it, and Karpenter launches another type when it can't get this one.", 

59) 

60@click.option( 

61 "--port", 

62 default=8000, 

63 help="Container port (default: 8000; SGLang's documented default is 30000)", 

64) 

65@click.option( 

66 "--model-path", 

67 help="Mount the inference namespace's EFS volume at /models without syncing " 

68 "anything (--model-source mounts it too); point -e MODEL at a path under it.", 

69) 

70@click.option( 

71 "--model-source", 

72 help="S3 URI for model weights (e.g. $(gco models uri llama3)), synced to " 

73 "/models/<endpoint name> in each region by an init container.", 

74) 

75@click.option("--health-path", default="/health", help="Health check path (default: /health)") 

76@click.option("--env", "-e", multiple=True, help="Environment variable (KEY=VALUE). Repeatable") 

77@click.option("--namespace", "-n", default="gco-inference", help="Kubernetes namespace") 

78@click.option("--label", "-l", multiple=True, help="Label (key=value). Repeatable") 

79@click.option("--min-replicas", type=int, default=None, help="Autoscaling: minimum replicas") 

80@click.option("--max-replicas", type=int, default=None, help="Autoscaling: maximum replicas") 

81@click.option( 

82 "--autoscale-metric", 

83 multiple=True, 

84 help="Autoscaling metric (cpu:70, memory:80, gpu:60). Repeatable. Enables " 

85 "autoscaling. CPU/memory scale via the native HPA; gpu (and gpu_memory) " 

86 "scale on CloudWatch GPU utilization via KEDA.", 

87) 

88@click.option( 

89 "--capacity-type", 

90 type=click.Choice(["on-demand", "spot"]), 

91 default=None, 

92 help="Node capacity type. 'spot' uses cheaper preemptible instances.", 

93) 

94@click.option( 

95 "--extra-args", 

96 multiple=True, 

97 help="Extra arguments passed to the container (e.g. '--kv-transfer-config {...}'). Repeatable.", 

98) 

99@click.option( 

100 "--accelerator", 

101 type=click.Choice(["nvidia", "neuron"]), 

102 default="nvidia", 

103 help="Accelerator type: 'nvidia' for GPU instances (default), 'neuron' for Trainium/Inferentia.", 

104) 

105@click.option( 

106 "--node-selector", 

107 multiple=True, 

108 help="Node selector (key=value). Repeatable. E.g. --node-selector eks.amazonaws.com/instance-family=inf2", 

109) 

110@click.option( 

111 "--no-rewrite-image", 

112 is_flag=True, 

113 default=False, 

114 help="Skip the per-region ECR URI rewrite. The image URI is sent verbatim " 

115 "to every target region (operator owns cross-region pulls).", 

116) 

117@click.option( 

118 "--mooncake-mode", 

119 type=click.Choice(["disaggregated", "store", "both"]), 

120 default=None, 

121 help="Enable Mooncake serving: 'disaggregated' splits prefill/decode, " 

122 "'store' runs a shared KV-cache store, 'both' composes the two. When set " 

123 "and -i is omitted, the default upstream Mooncake-enabled vLLM image is used.", 

124) 

125@click.option( 

126 "--prefill-replicas", 

127 type=int, 

128 default=1, 

129 help="Prefill instance count (X in an XpYd topology) for split modes.", 

130) 

131@click.option( 

132 "--decode-replicas", 

133 type=int, 

134 default=1, 

135 help="Decode instance count (Y in an XpYd topology) for split modes.", 

136) 

137@click.option( 

138 "--mooncake-protocol", 

139 type=click.Choice(["rdma", "tcp"]), 

140 default=None, 

141 help="Mooncake transfer intent. 'rdma' (the default) schedules role pods " 

142 "on EFA and configures vLLM's connector protocol as 'efa'; 'tcp' is the " 

143 "non-EFA fallback. Requires --mooncake-mode.", 

144) 

145@click.option( 

146 "--mooncake-device-name", 

147 default=None, 

148 help="Network device passed to Mooncake (for example efa_0 or eth0). " 

149 "Omit or pass an empty value for auto-detection. Requires --mooncake-mode.", 

150) 

151@click.option( 

152 "--mooncake-autoscale", 

153 multiple=True, 

154 help="Per-role Mooncake autoscaling as ROLE:MIN:MAX[:METRIC:TARGET ...], " 

155 "e.g. 'prefill:1:8' or 'decode:2:16:cpu:70:gpu:60'. Repeatable (one per " 

156 "role); append additional METRIC:TARGET pairs to scale a role on multiple " 

157 "metrics (cpu/memory via HPA, gpu/gpu_memory via KEDA CloudWatch). Requires " 

158 "--mooncake-mode disaggregated|both; populates spec.mooncake.autoscaling " 

159 "(distinct from the legacy --autoscale-metric/--min-replicas flags).", 

160) 

161@click.option( 

162 "--mooncake-cold-tier", 

163 is_flag=True, 

164 default=False, 

165 help="Enable the asynchronous per-region S3 cold tier for the shared " 

166 "KV-cache store (the cold tier extends the store). Pre-warm it with " 

167 "'gco inference populate-kv'. Requires --mooncake-mode store or both.", 

168) 

169@click.option( 

170 "--mooncake-proxy-image", 

171 default=None, 

172 help="Container image for the prefill-decode proxy (disaggregated/both). " 

173 "Defaults to the endpoint image, which bundles the reference proxy.", 

174) 

175@click.option( 

176 "--mooncake-admin-key-secret", 

177 default=None, 

178 help="Name of an existing Kubernetes Secret holding the prefill-decode " 

179 "proxy ADMIN_API_KEY. Optional: when omitted, each region's monitor " 

180 "auto-provisions a {name}-admin Secret with a generated key.", 

181) 

182@pass_config 

183def inference_deploy( 

184 config: Any, 

185 endpoint_name: Any, 

186 image: Any, 

187 framework: Any, 

188 region: Any, 

189 replicas: Any, 

190 gpu_count: Any, 

191 gpu_type: Any, 

192 port: Any, 

193 model_path: Any, 

194 model_source: Any, 

195 health_path: Any, 

196 env: Any, 

197 namespace: Any, 

198 label: Any, 

199 min_replicas: Any, 

200 max_replicas: Any, 

201 autoscale_metric: Any, 

202 capacity_type: Any, 

203 extra_args: Any, 

204 accelerator: Any, 

205 node_selector: Any, 

206 no_rewrite_image: Any, 

207 mooncake_mode: Any, 

208 prefill_replicas: Any, 

209 decode_replicas: Any, 

210 mooncake_protocol: Any, 

211 mooncake_device_name: Any, 

212 mooncake_autoscale: Any, 

213 mooncake_cold_tier: Any, 

214 mooncake_proxy_image: Any, 

215 mooncake_admin_key_secret: Any, 

216) -> None: 

217 """Deploy an inference endpoint to one or more regions. 

218 

219 The endpoint is registered in DynamoDB and the inference_monitor 

220 in each target region creates the Kubernetes resources automatically. 

221 

222 Examples: 

223 gco inference deploy my-llm -i vllm/vllm-openai:v0.32.0 

224 

225 gco inference deploy qwen \\ 

226 -i vllm/vllm-openai:v0.32.0 \\ 

227 -e MODEL=Qwen/Qwen2.5-1.5B-Instruct \\ 

228 --extra-args=--max-model-len --extra-args 4096 

229 

230 gco inference deploy llama3-70b \\ 

231 -i vllm/vllm-openai:v0.32.0 \\ 

232 -r us-east-1 -r eu-west-1 \\ 

233 --replicas 2 --gpu-count 4 \\ 

234 --model-source $(gco models uri llama3-70b) \\ 

235 -e MODEL=/models/llama3-70b 

236 

237 vLLM is started with --model $(MODEL) unless --extra-args already name 

238 the model; each --extra-args value is one server argument. 

239 """ 

240 from ..inference import get_inference_manager 

241 

242 formatter = get_output_formatter(config) 

243 

244 # Parse env vars and labels 

245 env_dict = {} 

246 for e_var in env: 

247 if "=" in e_var: 

248 k, v = e_var.split("=", 1) 

249 env_dict[k] = v 

250 

251 labels_dict = {} 

252 for lbl in label: 

253 if "=" in lbl: 

254 k, v = lbl.split("=", 1) 

255 labels_dict[k] = v 

256 

257 node_selector_dict = {} 

258 for ns in node_selector: 

259 if "=" in ns: 

260 k, v = ns.split("=", 1) 

261 node_selector_dict[k] = v 

262 

263 # Build autoscaling config 

264 autoscaling_config = None 

265 if autoscale_metric: 

266 metrics = [] 

267 for m in autoscale_metric: 

268 if ":" in m: 

269 mtype, mtarget = m.split(":", 1) 

270 metrics.append({"type": mtype, "target": int(mtarget)}) 

271 else: 

272 metrics.append({"type": m, "target": 70}) 

273 autoscaling_config = { 

274 "enabled": True, 

275 "min_replicas": min_replicas or 1, 

276 "max_replicas": max_replicas or 10, 

277 "metrics": metrics, 

278 } 

279 

280 # Transfer overrides are meaningful only when a Mooncake block is being 

281 # authored. With no override, the monitor resolves the default RDMA intent 

282 # to vLLM's explicit EFA connector protocol and auto-detects the device. 

283 if (mooncake_protocol is not None or mooncake_device_name is not None) and not mooncake_mode: 

284 formatter.print_error( 

285 "--mooncake-protocol and --mooncake-device-name require --mooncake-mode." 

286 ) 

287 sys.exit(1) 

288 

289 mooncake_transfer_config: dict[str, Any] | None = None 

290 if mooncake_protocol is not None or mooncake_device_name is not None: 

291 mooncake_transfer_config = {} 

292 if mooncake_protocol is not None: 

293 mooncake_transfer_config["protocol"] = mooncake_protocol 

294 if mooncake_device_name is not None: 

295 mooncake_transfer_config["device_name"] = mooncake_device_name 

296 

297 # Build per-role Mooncake autoscaling config (spec.mooncake.autoscaling). 

298 # This is distinct from the legacy single-Deployment autoscaling above: 

299 # each ROLE:MIN:MAX token sets a role's bounds, and any number of trailing 

300 # METRIC:TARGET pairs add scaling signals for that role. Bounds and metrics 

301 # are validated fail-fast in the deploy path before anything is persisted. 

302 mooncake_autoscaling_config: dict[str, Any] | None = None 

303 if mooncake_autoscale: 

304 if not mooncake_mode: 

305 formatter.print_error( 

306 "--mooncake-autoscale requires --mooncake-mode (disaggregated or both)." 

307 ) 

308 sys.exit(1) 

309 mooncake_autoscaling_config = {"enabled": True} 

310 for entry in mooncake_autoscale: 

311 parts = entry.split(":") 

312 # ROLE:MIN:MAX, then zero or more METRIC:TARGET pairs. 

313 if len(parts) < 3 or (len(parts) - 3) % 2 != 0: 

314 formatter.print_error( 

315 f"Invalid --mooncake-autoscale value '{entry}'. Expected " 

316 "ROLE:MIN:MAX optionally followed by METRIC:TARGET pairs." 

317 ) 

318 sys.exit(1) 

319 role = parts[0] 

320 if role not in ("prefill", "decode"): 

321 formatter.print_error( 

322 f"Invalid --mooncake-autoscale role '{role}'. Expected 'prefill' or 'decode'." 

323 ) 

324 sys.exit(1) 

325 try: 

326 role_block: dict[str, Any] = { 

327 "min_replicas": int(parts[1]), 

328 "max_replicas": int(parts[2]), 

329 } 

330 metric_tokens = parts[3:] 

331 metrics = [ 

332 {"type": metric_tokens[i], "target": int(metric_tokens[i + 1])} 

333 for i in range(0, len(metric_tokens), 2) 

334 ] 

335 if metrics: 

336 role_block["metrics"] = metrics 

337 except ValueError: 

338 formatter.print_error( 

339 f"Invalid --mooncake-autoscale numbers in '{entry}'. MIN, MAX, " 

340 "and each TARGET must be integers." 

341 ) 

342 sys.exit(1) 

343 mooncake_autoscaling_config[role] = role_block 

344 

345 # --mooncake-cold-tier opts into the async per-region S3 cold tier, which 

346 # extends the shared store, so it only applies to store/both modes. 

347 if mooncake_cold_tier and mooncake_mode not in ("store", "both"): 

348 formatter.print_error( 

349 "--mooncake-cold-tier requires --mooncake-mode store or both " 

350 "(the cold tier extends the shared KV-cache store)." 

351 ) 

352 sys.exit(1) 

353 

354 mooncake_store_config: dict[str, Any] | None = None 

355 if mooncake_cold_tier: 

356 mooncake_store_config = {"enabled": True, "cold_tier_enabled": True} 

357 

358 # Configure the prefill-decode proxy that fronts split modes: an explicit 

359 # image (otherwise it defaults to the endpoint image) and the name of the 

360 # Kubernetes Secret holding its ADMIN_API_KEY. 

361 mooncake_proxy_config: dict[str, Any] | None = None 

362 if mooncake_proxy_image or mooncake_admin_key_secret: 

363 mooncake_proxy_config = {} 

364 if mooncake_proxy_image: 

365 mooncake_proxy_config["image"] = mooncake_proxy_image 

366 if mooncake_admin_key_secret: 

367 mooncake_proxy_config["admin_api_key_secret"] = mooncake_admin_key_secret 

368 

369 # When no admin-key Secret is named, each region's monitor auto-provisions a 

370 # {name}-admin Secret with a generated key, so no manual step is needed. 

371 if mooncake_mode in ("disaggregated", "both") and not mooncake_admin_key_secret: 

372 formatter.print_info( 

373 "No --mooncake-admin-key-secret given; each region's inference " 

374 "monitor will auto-provision a '{name}-admin' Secret with a " 

375 "generated ADMIN_API_KEY. Pass --mooncake-admin-key-secret to use " 

376 "your own Secret instead." 

377 ) 

378 

379 try: 

380 manager = get_inference_manager(config) 

381 result = manager.deploy( 

382 endpoint_name=endpoint_name, 

383 image=image, 

384 framework=framework, 

385 target_regions=list(region) if region else None, 

386 replicas=replicas, 

387 gpu_count=gpu_count, 

388 gpu_type=gpu_type, 

389 port=port, 

390 model_path=model_path, 

391 model_source=model_source, 

392 health_check_path=health_path, 

393 env=env_dict if env_dict else None, 

394 namespace=namespace, 

395 labels=labels_dict if labels_dict else None, 

396 autoscaling=autoscaling_config, 

397 capacity_type=capacity_type, 

398 extra_args=list(extra_args) if extra_args else None, 

399 accelerator=accelerator, 

400 node_selector=node_selector_dict if node_selector_dict else None, 

401 rewrite_image=not no_rewrite_image, 

402 mooncake_mode=mooncake_mode, 

403 prefill_replicas=prefill_replicas, 

404 decode_replicas=decode_replicas, 

405 mooncake_store=mooncake_store_config, 

406 mooncake_transfer=mooncake_transfer_config, 

407 mooncake_proxy=mooncake_proxy_config, 

408 mooncake_autoscaling=mooncake_autoscaling_config, 

409 ) 

410 

411 formatter.print_success(f"Endpoint '{endpoint_name}' registered for deployment") 

412 regions_str = ", ".join(result.get("target_regions", [])) 

413 formatter.print_info(f"Target regions: {regions_str}") 

414 formatter.print_info(f"Ingress path: {result.get('ingress_path', '')}") 

415 formatter.print_info( 

416 "The inference_monitor in each region will create the resources. " 

417 "Use 'gco inference status' to track progress." 

418 ) 

419 

420 # Warn if deploying to a subset of regions. The endpoint is already 

421 # registered by now, so a failed lookup here is only a missing 

422 # warning: reporting "Failed to deploy endpoint" and exiting 1 would 

423 # misstate a deploy that succeeded. 

424 if region: 

425 from ..aws_client import get_aws_client as _get_client 

426 

427 try: 

428 all_stacks = _get_client(config).discover_regional_stacks() 

429 except Exception as lookup_error: 

430 formatter.print_warning( 

431 "Could not list the other deployed Regions to compare with -r " 

432 f"({lookup_error}); the endpoint is registered for: {regions_str}." 

433 ) 

434 else: 

435 all_regions = set(all_stacks.keys()) 

436 target_set = set(result.get("target_regions", [])) 

437 missing = all_regions - target_set 

438 if missing: 

439 formatter.print_warning( 

440 f"Endpoint is NOT deployed to: {', '.join(sorted(missing))}. " 

441 "Global Accelerator may route users to those regions where " 

442 "the endpoint won't exist. Consider deploying to all regions " 

443 "(omit -r) for consistent global routing." 

444 ) 

445 

446 if config.output_format != "table": 

447 formatter.print(result) 

448 

449 except ValueError as e: 

450 formatter.print_error(str(e)) 

451 sys.exit(1) 

452 except Exception as e: 

453 formatter.print_error(f"Failed to deploy endpoint: {e}") 

454 sys.exit(1) 

455 

456 

457@inference.command("list") 

458@click.option("--state", "-s", help="Filter by state (deploying, running, stopped, deleted)") 

459@click.option("--region", "-r", help="Filter by target region") 

460@pass_config 

461def inference_list(config: Any, state: Any, region: Any) -> None: 

462 """List inference endpoints. 

463 

464 Examples: 

465 gco inference list 

466 gco inference list --state running 

467 gco inference list -r us-east-1 

468 """ 

469 from ..inference import get_inference_manager 

470 

471 formatter = get_output_formatter(config) 

472 

473 try: 

474 manager = get_inference_manager(config) 

475 endpoints = manager.list_endpoints(desired_state=state, region=region) 

476 

477 if config.output_format != "table": 

478 formatter.print(endpoints) 

479 return 

480 

481 if not endpoints: 

482 formatter.print_info("No inference endpoints found") 

483 return 

484 

485 print(f"\n Inference Endpoints ({len(endpoints)} found)") 

486 print(" " + "-" * 85) 

487 print(f" {'NAME':<25} {'STATE':<12} {'REGIONS':<25} {'REPLICAS':>8} {'IMAGE'}") 

488 print(" " + "-" * 85) 

489 for ep in endpoints: 

490 name = ep.get("endpoint_name", "")[:24] 

491 ep_state = ep.get("desired_state", "unknown") 

492 regions = ", ".join(ep.get("target_regions", []))[:24] 

493 spec = ep.get("spec", {}) 

494 replicas = spec.get("replicas", 1) if isinstance(spec, dict) else 1 

495 image = spec.get("image", "")[:40] if isinstance(spec, dict) else "" 

496 print(f" {name:<25} {ep_state:<12} {regions:<25} {replicas:>8} {image}") 

497 

498 print() 

499 

500 except Exception as e: 

501 formatter.print_error(f"Failed to list endpoints: {e}") 

502 sys.exit(1) 

503 

504 

505@inference.command("status") 

506@click.argument("endpoint_name") 

507@pass_config 

508def inference_status(config: Any, endpoint_name: Any) -> None: 

509 """Show detailed status of an inference endpoint. 

510 

511 Examples: 

512 gco inference status my-llm 

513 """ 

514 from ..inference import get_inference_manager 

515 

516 formatter = get_output_formatter(config) 

517 

518 try: 

519 manager = get_inference_manager(config) 

520 endpoint = manager.get_endpoint(endpoint_name) 

521 

522 if not endpoint: 

523 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

524 sys.exit(1) 

525 

526 if config.output_format != "table": 

527 formatter.print(endpoint) 

528 return 

529 

530 spec = endpoint.get("spec", {}) 

531 print(f"\n Endpoint: {endpoint_name}") 

532 print(" " + "-" * 60) 

533 print(f" State: {endpoint.get('desired_state', 'unknown')}") 

534 print(f" Image: {spec.get('image', 'N/A')}") 

535 print(f" Replicas: {spec.get('replicas', 1)}") 

536 print(f" GPUs: {spec.get('gpu_count', 0)}") 

537 print(f" Port: {spec.get('port', 8000)}") 

538 print(f" Path: {endpoint.get('ingress_path', 'N/A')}") 

539 print(f" Namespace: {endpoint.get('namespace', 'N/A')}") 

540 print(f" Created: {endpoint.get('created_at', 'N/A')}") 

541 

542 # Region status 

543 region_status = endpoint.get("region_status", {}) 

544 if region_status: 

545 print("\n Region Status:") 

546 print(f" {'REGION':<18} {'STATE':<12} {'READY':>5} {'DESIRED':>7} {'LAST SYNC'}") 

547 print(" " + "-" * 65) 

548 for r, status in region_status.items(): 

549 if isinstance(status, dict): 

550 r_state = status.get("state", "unknown") 

551 ready = status.get("replicas_ready", 0) 

552 desired = status.get("replicas_desired", 0) 

553 last_sync = status.get("last_sync", "N/A") 

554 if last_sync and len(last_sync) > 19: 

555 last_sync = last_sync[:19] 

556 print(f" {r:<18} {r_state:<12} {ready:>5} {desired:>7} {last_sync}") 

557 else: 

558 target_regions = endpoint.get("target_regions", []) 

559 print(f"\n Target regions: {', '.join(target_regions)}") 

560 print(" (Waiting for inference_monitor to sync)") 

561 

562 print() 

563 

564 except Exception as e: 

565 formatter.print_error(f"Failed to get endpoint status: {e}") 

566 sys.exit(1) 

567 

568 

569@inference.command("scale") 

570@click.argument("endpoint_name") 

571@click.option("--replicas", "-r", required=True, type=int, help="New replica count") 

572@pass_config 

573def inference_scale(config: Any, endpoint_name: Any, replicas: Any) -> None: 

574 """Scale an inference endpoint. 

575 

576 Examples: 

577 gco inference scale my-llm --replicas 4 

578 """ 

579 from ..inference import get_inference_manager 

580 

581 formatter = get_output_formatter(config) 

582 

583 try: 

584 manager = get_inference_manager(config) 

585 result = manager.scale(endpoint_name, replicas) 

586 

587 if result: 

588 formatter.print_success(f"Endpoint '{endpoint_name}' scaled to {replicas} replicas") 

589 else: 

590 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

591 sys.exit(1) 

592 

593 except Exception as e: 

594 formatter.print_error(f"Failed to scale endpoint: {e}") 

595 sys.exit(1) 

596 

597 

598@inference.command("stop") 

599@click.argument("endpoint_name") 

600@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

601@pass_config 

602def inference_stop(config: Any, endpoint_name: Any, yes: Any) -> None: 

603 """Stop an inference endpoint (scale to zero, keep config). 

604 

605 Examples: 

606 gco inference stop my-llm -y 

607 """ 

608 from ..inference import get_inference_manager 

609 

610 formatter = get_output_formatter(config) 

611 

612 if not yes: 

613 confirm(f"Stop endpoint '{endpoint_name}'?", abort=True) 

614 

615 try: 

616 manager = get_inference_manager(config) 

617 result = manager.stop(endpoint_name) 

618 

619 if result: 

620 formatter.print_success(f"Endpoint '{endpoint_name}' marked for stop") 

621 else: 

622 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

623 sys.exit(1) 

624 

625 except Exception as e: 

626 formatter.print_error(f"Failed to stop endpoint: {e}") 

627 sys.exit(1) 

628 

629 

630@inference.command("start") 

631@click.argument("endpoint_name") 

632@pass_config 

633def inference_start(config: Any, endpoint_name: Any) -> None: 

634 """Start a stopped inference endpoint. 

635 

636 Examples: 

637 gco inference start my-llm 

638 """ 

639 from ..inference import get_inference_manager 

640 

641 formatter = get_output_formatter(config) 

642 

643 try: 

644 manager = get_inference_manager(config) 

645 result = manager.start(endpoint_name) 

646 

647 if result: 

648 formatter.print_success(f"Endpoint '{endpoint_name}' marked for start") 

649 else: 

650 formatter.print_error( 

651 f"Endpoint '{endpoint_name}' is not a stopped endpoint. If it was deleted " 

652 "or purged, redeploy it with 'gco inference deploy'." 

653 ) 

654 sys.exit(1) 

655 

656 except Exception as e: 

657 formatter.print_error(f"Failed to start endpoint: {e}") 

658 sys.exit(1) 

659 

660 

661def _warn_missing_regions( 

662 formatter: Any, 

663 endpoint_name: str, 

664 target_regions: list[str], 

665 deployed_regions: set[str], 

666) -> None: 

667 """Warn when deployed Regions are left out of an endpoint's Region list. 

668 

669 Global Accelerator routes each request to the closest healthy Region, so a 

670 request for this endpoint can land in a Region that does not serve it and 

671 get a 404 there. 

672 """ 

673 missing = sorted(deployed_regions - set(target_regions)) 

674 if missing: 

675 formatter.print_warning( 

676 f"Endpoint '{endpoint_name}' is NOT deployed to: {', '.join(missing)}. " 

677 "Global Accelerator may route users to those regions where the endpoint " 

678 "won't exist. Add them with 'gco inference add-region'." 

679 ) 

680 

681 

682@inference.command("add-region") 

683@click.argument("endpoint_name") 

684@click.argument("region") 

685@pass_config 

686def inference_add_region(config: Any, endpoint_name: Any, region: Any) -> None: 

687 """Add a Region to an existing inference endpoint. 

688 

689 An endpoint keeps the Region list it was deployed with, so a Region added to 

690 the deployment later does not run it. This command adds REGION to that list; 

691 the inference monitor in REGION then creates the endpoint from its stored 

692 spec on its next pass. REGION must already run a GCO regional stack. 

693 

694 Examples: 

695 gco inference add-region my-llm us-west-2 

696 """ 

697 from ..aws_client import get_aws_client 

698 from ..inference import get_inference_manager 

699 

700 formatter = get_output_formatter(config) 

701 

702 try: 

703 manager = get_inference_manager(config) 

704 endpoint = manager.get_endpoint(endpoint_name) 

705 if endpoint is None: 

706 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

707 sys.exit(1) 

708 current = list(endpoint.get("target_regions") or []) 

709 if region in current: 

710 formatter.print_info( 

711 f"Endpoint '{endpoint_name}' already targets {region}: {', '.join(current)}" 

712 ) 

713 return 

714 deployed = set(get_aws_client(config).discover_regional_stacks()) 

715 if region not in deployed: 

716 formatter.print_error( 

717 f"No GCO regional stack is deployed in {region}. Deploy it first with " 

718 f"'gco stacks regions add {region} -y' and 'gco stacks deploy-all -y'." 

719 ) 

720 sys.exit(1) 

721 result = manager.add_region(endpoint_name, region) 

722 if result is None: 

723 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

724 sys.exit(1) 

725 targets = list(result.get("target_regions") or []) 

726 formatter.print_success(f"Endpoint '{endpoint_name}' now targets: {', '.join(targets)}") 

727 formatter.print_info( 

728 f"The inference monitor in {region} creates the endpoint's resources. " 

729 f"Use 'gco inference status {endpoint_name}' to track progress." 

730 ) 

731 _warn_missing_regions(formatter, endpoint_name, targets, deployed) 

732 if config.output_format != "table": 

733 formatter.print(result) 

734 except ValueError as e: 

735 formatter.print_error(str(e)) 

736 sys.exit(1) 

737 except Exception as e: 

738 formatter.print_error(f"Failed to add {region} to endpoint '{endpoint_name}': {e}") 

739 sys.exit(1) 

740 

741 

742@inference.command("remove-region") 

743@click.argument("endpoint_name") 

744@click.argument("region") 

745@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

746@pass_config 

747def inference_remove_region(config: Any, endpoint_name: Any, region: Any, yes: Any) -> None: 

748 """Remove a Region from an inference endpoint. 

749 

750 The inference monitor in REGION deletes the endpoint's resources there, and 

751 the endpoint keeps serving from its other Regions. Removing the endpoint's 

752 last Region is refused; use 'gco inference stop' or 'gco inference delete' 

753 instead. 

754 

755 Examples: 

756 gco inference remove-region my-llm us-west-2 -y 

757 """ 

758 from ..aws_client import get_aws_client 

759 from ..inference import get_inference_manager 

760 

761 formatter = get_output_formatter(config) 

762 

763 try: 

764 manager = get_inference_manager(config) 

765 endpoint = manager.get_endpoint(endpoint_name) 

766 if endpoint is None: 

767 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

768 sys.exit(1) 

769 current = list(endpoint.get("target_regions") or []) 

770 if region not in current: 

771 formatter.print_info( 

772 f"Endpoint '{endpoint_name}' does not target {region}: {', '.join(current)}" 

773 ) 

774 return 

775 if current == [region]: 

776 formatter.print_error( 

777 f"{region} is the only Region endpoint '{endpoint_name}' runs in. Use " 

778 f"'gco inference stop {endpoint_name}' or 'gco inference delete " 

779 f"{endpoint_name}' instead." 

780 ) 

781 sys.exit(1) 

782 if not yes: 

783 confirm( 

784 f"Remove {region} from endpoint '{endpoint_name}'? Its resources in " 

785 f"{region} are deleted.", 

786 abort=True, 

787 ) 

788 result = manager.remove_region(endpoint_name, region) 

789 if result is None: 

790 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

791 sys.exit(1) 

792 targets = list(result.get("target_regions") or []) 

793 formatter.print_success(f"Endpoint '{endpoint_name}' now targets: {', '.join(targets)}") 

794 formatter.print_info( 

795 f"The inference monitor in {region} deletes the endpoint's resources there." 

796 ) 

797 try: 

798 deployed = set(get_aws_client(config).discover_regional_stacks()) 

799 except Exception as lookup_error: 

800 formatter.print_warning( 

801 f"Could not list the deployed Regions ({lookup_error}); requests that " 

802 f"Global Accelerator sends to {region} for this endpoint get a 404." 

803 ) 

804 else: 

805 _warn_missing_regions(formatter, endpoint_name, targets, deployed) 

806 if config.output_format != "table": 

807 formatter.print(result) 

808 except click.Abort: 

809 raise 

810 except ValueError as e: 

811 formatter.print_error(str(e)) 

812 sys.exit(1) 

813 except Exception as e: 

814 formatter.print_error(f"Failed to remove {region} from endpoint '{endpoint_name}': {e}") 

815 sys.exit(1) 

816 

817 

818@inference.command("delete") 

819@click.argument("endpoint_name") 

820@click.option( 

821 "--expected-owner-label", 

822 default=None, 

823 metavar="KEY=VALUE", 

824 hidden=True, 

825 help="Require an exact stored owner label in the same atomic delete-state update", 

826) 

827@click.option( 

828 "--expected-lifecycle-id", 

829 default=None, 

830 hidden=True, 

831 help="Require the immutable endpoint lifecycle observed by an automation client", 

832) 

833@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

834@pass_config 

835def inference_delete( 

836 config: Any, 

837 endpoint_name: Any, 

838 expected_owner_label: Any, 

839 expected_lifecycle_id: Any, 

840 yes: Any, 

841) -> None: 

842 """Delete an inference endpoint from all regions. 

843 

844 The inference_monitor in each region will clean up the K8s resources. 

845 

846 Examples: 

847 gco inference delete my-llm -y 

848 """ 

849 from ..inference import get_inference_manager 

850 

851 formatter = get_output_formatter(config) 

852 

853 owner_condition: tuple[str, str] | None = None 

854 if expected_owner_label is not None: 

855 label_name, separator, label_value = str(expected_owner_label).partition("=") 

856 if not separator or not label_name or not label_value: 

857 formatter.print_error("--expected-owner-label must be KEY=VALUE") 

858 sys.exit(1) 

859 owner_condition = (label_name, label_value) 

860 lifecycle_condition = ( 

861 str(expected_lifecycle_id).strip() if expected_lifecycle_id is not None else None 

862 ) 

863 if lifecycle_condition == "": 

864 formatter.print_error("--expected-lifecycle-id must be non-empty") 

865 sys.exit(1) 

866 if (owner_condition is None) != (lifecycle_condition is None): 

867 formatter.print_error( 

868 "--expected-owner-label and --expected-lifecycle-id must be supplied together" 

869 ) 

870 sys.exit(1) 

871 

872 if not yes: 

873 confirm(f"Delete endpoint '{endpoint_name}' from all regions?", abort=True) 

874 

875 try: 

876 manager = get_inference_manager(config) 

877 result = manager.delete( 

878 endpoint_name, 

879 expected_owner_label=owner_condition, 

880 expected_lifecycle_id=lifecycle_condition, 

881 ) 

882 

883 if result: 

884 formatter.print_success( 

885 f"Endpoint '{endpoint_name}' marked for deletion. " 

886 "The inference_monitor will clean up resources in each region." 

887 ) 

888 else: 

889 suffix = " or ownership/lifecycle condition failed" if owner_condition else "" 

890 formatter.print_error(f"Endpoint '{endpoint_name}' not found{suffix}") 

891 sys.exit(1) 

892 

893 except Exception as e: 

894 formatter.print_error(f"Failed to delete endpoint: {e}") 

895 sys.exit(1) 

896 

897 

898@inference.command("update-image") 

899@click.argument("endpoint_name") 

900@click.option("--image", "-i", required=True, help="New container image") 

901@pass_config 

902def inference_update_image(config: Any, endpoint_name: Any, image: Any) -> None: 

903 """Update the container image for an inference endpoint. 

904 

905 Triggers a rolling update across all target regions. 

906 

907 Examples: 

908 gco inference update-image my-llm -i vllm/vllm-openai:v0.32.0 

909 """ 

910 from ..inference import get_inference_manager 

911 

912 formatter = get_output_formatter(config) 

913 

914 try: 

915 manager = get_inference_manager(config) 

916 result = manager.update_image(endpoint_name, image) 

917 

918 if result: 

919 formatter.print_success(f"Endpoint '{endpoint_name}' image updated to {image}") 

920 formatter.print_info("Rolling update will be applied by inference_monitor") 

921 else: 

922 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

923 sys.exit(1) 

924 

925 except Exception as e: 

926 formatter.print_error(f"Failed to update image: {e}") 

927 sys.exit(1) 

928 

929 

930#: Why ``invoke`` can't build a Triton request from a prompt, and what to pass. 

931_TRITON_INVOKE_HINT = ( 

932 "Triton serves models by name and takes input tensors, not a prompt: pass " 

933 "--path /v2/models/<model>/infer and --data with a KServe v2 inference request, " 

934 'e.g. --data \'{"inputs": [{"name": "INPUT0", "shape": [3], "datatype": "FP32", ' 

935 '"data": [1, 2, 3]}]}\'. `gco inference models <name>` lists the loaded models.' 

936) 

937 

938 

939def _resolve_invoke_framework(persisted_framework: Any, image_lower: str) -> str: 

940 """Return the serving contract ``invoke``/``models`` should speak to an endpoint. 

941 

942 The persisted ``framework`` wins; image heuristics only cover records 

943 created before the field existed. Anything unrecognised is treated as an 

944 OpenAI-compatible server. 

945 """ 

946 if persisted_framework in ("vllm", "sglang"): 

947 return str(persisted_framework) 

948 if "vllm" in image_lower: 

949 return "vllm" 

950 if "sglang" in image_lower: 

951 return "sglang" 

952 if "tritonserver" in image_lower or "triton" in image_lower: 

953 return "triton" 

954 return "openai" 

955 

956 

957def _flag_value(args: list[str], flag: str) -> str | None: 

958 """The value of ``flag`` in ``args``, given as ``flag value`` or ``flag=value``.""" 

959 for index, arg in enumerate(args): 

960 if arg == flag and index + 1 < len(args): 

961 return args[index + 1] 

962 if arg.startswith(f"{flag}="): 

963 return arg.split("=", 1)[1] 

964 return None 

965 

966 

967def _openai_request_model(spec: dict[str, Any], *, vllm: bool) -> str | None: 

968 """The ``model`` an OpenAI-compatible request to this endpoint must name. 

969 

970 Mirrors how the renderer starts vLLM: ``--served-model-name`` is the name 

971 the API answers to; otherwise the model the arguments name (``--model``, 

972 or the positional tag ``vllm serve`` takes first) wins over the ``MODEL`` 

973 env convention. ``None`` means the spec doesn't say, including a model 

974 named inside a ``--config`` file, so the caller asks ``/v1/models``. 

975 """ 

976 args = [str(arg) for arg in spec.get("args") or []] 

977 for flag in ("--served-model-name", "--model"): 

978 value = _flag_value(args, flag) 

979 if value: 

980 return value 

981 if vllm and args and not args[0].startswith("-"): 

982 return args[0] 

983 if vllm and any(arg == "--config" or arg.startswith("--config=") for arg in args): 

984 return None 

985 model = (spec.get("env") or {}).get("MODEL") 

986 return str(model) if model else None 

987 

988 

989def _model_named_only_by_env(spec: Any, model: str) -> bool: 

990 """True when ``model`` comes from the spec's ``MODEL`` alone, not its args.""" 

991 if not isinstance(spec, dict) or (spec.get("env") or {}).get("MODEL") != model: 

992 return False 

993 args = [str(arg) for arg in spec.get("args") or []] 

994 if args and not args[0].startswith("-"): 

995 return False 

996 return all(_flag_value(args, flag) is None for flag in ("--served-model-name", "--model")) 

997 

998 

999def _served_model_hint( 

1000 client: Any, 

1001 *, 

1002 endpoint_name: str, 

1003 endpoint_path: str, 

1004 requested_model: str, 

1005 region: str | None, 

1006 spec: Any, 

1007) -> str | None: 

1008 """Explain a 404 for a model the endpoint does not serve, or return None. 

1009 

1010 Asks the endpoint's ``/v1/models`` which models it serves. The hint is 

1011 given only when that inventory answers and leaves out the requested model, 

1012 so a 404 with any other cause keeps the server's own message alone. 

1013 """ 

1014 try: 

1015 response = client.make_authenticated_request( 

1016 method="GET", 

1017 path=f"{endpoint_path}/v1/models", 

1018 target_region=region, 

1019 ) 

1020 if not response.ok: 

1021 return None 

1022 data = response.json().get("data", []) 

1023 served = [str(item["id"]) for item in data if isinstance(item, dict) and item.get("id")] 

1024 except Exception: # nosec B110 # the hint is best-effort 

1025 return None 

1026 if not served or requested_model in served: 

1027 return None 

1028 

1029 region_flag = f" -r {region}" if region else "" 

1030 example = json.dumps( 

1031 {"model": served[0], "prompt": "Hello", "max_tokens": 50}, separators=(", ", ": ") 

1032 ) 

1033 lines = [f"{endpoint_name} serves {', '.join(served)}, not {requested_model}."] 

1034 if _model_named_only_by_env(spec, requested_model): 

1035 lines.append( 

1036 "vLLM does not read MODEL. Endpoints deployed with -e MODEL before v8.12.0 " 

1037 "were started without it and serve vLLM's default model, and an upgrade does " 

1038 f"not change a running endpoint. Recreate it to serve {requested_model}; see " 

1039 "'Invoke Says the Model Does Not Exist' in docs/TROUBLESHOOTING.md." 

1040 ) 

1041 lines.append( 

1042 f"To call the served model now: gco inference invoke {endpoint_name}{region_flag} " 

1043 f"-d '{example}'" 

1044 ) 

1045 return "\n".join(lines) 

1046 

1047 

1048_SGLANG_SERVER_INFO_IDENTITY_KEYS = ( 

1049 "model_path", 

1050 "served_model_name", 

1051 "revision", 

1052 "tokenizer_path", 

1053 "version", 

1054) 

1055 

1056 

1057@inference.command("invoke") 

1058@click.argument("endpoint_name") 

1059@click.option("--prompt", "-p", help="Text prompt to send") 

1060@click.option("--data", "-d", help="Raw JSON body to send") 

1061@click.option( 

1062 "--path", "api_path", default=None, help="API sub-path (default: auto-detect from framework)" 

1063) 

1064@click.option("--region", "-r", help="Target region for the request") 

1065@click.option( 

1066 "--max-tokens", type=int, default=100, help="Maximum tokens to generate (default: 100)" 

1067) 

1068@click.option( 

1069 "--stream/--no-stream", 

1070 default=None, 

1071 help="Enable or disable incremental response streaming. Raw JSON with " 

1072 "'stream': true enables streaming automatically.", 

1073) 

1074@pass_config 

1075def inference_invoke( 

1076 config: Any, 

1077 endpoint_name: Any, 

1078 prompt: Any, 

1079 data: Any, 

1080 api_path: Any, 

1081 region: Any, 

1082 max_tokens: Any, 

1083 stream: Any, 

1084) -> None: 

1085 """Send a request to an inference endpoint and print the response. 

1086 

1087 Automatically discovers the endpoint's stored API path (the legacy 

1088 ``ingress_path`` record field) and routes the request through API Gateway 

1089 with SigV4 authentication. 

1090 

1091 Examples: 

1092 gco inference invoke my-llm -p "What is GPU orchestration?" 

1093 

1094 gco inference invoke my-llm -d '{"prompt": "Hello", "max_tokens": 50}' 

1095 

1096 gco inference invoke my-llm -p "Explain K8s" --path /v1/completions 

1097 

1098 gco inference invoke d1-decisions --path /v1/systemone \\ 

1099 -d '{"state": "Shipped 9 days late", "questions": {"late": {"type": "noul"}}}' 

1100 """ 

1101 import json as _json 

1102 

1103 from ..aws_client import get_aws_client 

1104 from ..inference import get_inference_manager 

1105 

1106 formatter = get_output_formatter(config) 

1107 

1108 if not prompt and not data: 

1109 formatter.print_error("Provide --prompt (-p) or --data (-d)") 

1110 sys.exit(1) 

1111 

1112 try: 

1113 # Look up the endpoint's stored API prefix and serving spec. The record 

1114 # retains the historical ``ingress_path`` field name for compatibility; 

1115 # requests still traverse only the shared authenticated Ingress. 

1116 manager = get_inference_manager(config) 

1117 endpoint = manager.get_endpoint(endpoint_name) 

1118 if not endpoint: 

1119 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

1120 sys.exit(1) 

1121 

1122 endpoint_path = endpoint.get("ingress_path", f"/inference/{endpoint_name}") 

1123 spec = endpoint.get("spec", {}) 

1124 image = spec.get("image", "") if isinstance(spec, dict) else "" 

1125 image_lower = image.lower() if isinstance(image, str) else "" 

1126 persisted_framework = spec.get("framework") if isinstance(spec, dict) else None 

1127 

1128 parsed_data: dict[str, Any] | None = None 

1129 if data: 

1130 parsed_json = _json.loads(data) 

1131 if not isinstance(parsed_json, dict): 

1132 raise ValueError("--data must contain a JSON object") 

1133 parsed_data = parsed_json 

1134 

1135 # An explicit flag wins over the body. Without a flag, raw OpenAI JSON 

1136 # can opt into streamed transport by carrying its normal stream field. 

1137 if stream is None: 

1138 stream_response = parsed_data is not None and parsed_data.get("stream") is True 

1139 else: 

1140 stream_response = bool(stream) 

1141 if parsed_data is not None and stream is not None: 

1142 parsed_data["stream"] = stream_response 

1143 

1144 # Persisted framework is authoritative for neutral/private image names; 

1145 # image heuristics remain only for legacy records created before that 

1146 # field existed. 

1147 resolved_framework = _resolve_invoke_framework(persisted_framework, image_lower) 

1148 if resolved_framework == "triton" and (api_path is None or parsed_data is None): 

1149 # Triton has no prompt API: an inference request names a model and 

1150 # carries its input tensors, so neither can be guessed here. 

1151 formatter.print_error(_TRITON_INVOKE_HINT) 

1152 sys.exit(1) 

1153 if api_path is None: 

1154 # Native SGLang API; streaming is a body flag on the same path. 

1155 api_path = "/generate" if resolved_framework == "sglang" else "/v1/completions" 

1156 

1157 full_path = f"{endpoint_path}{api_path}" 

1158 

1159 # Build the request body. 

1160 body: dict[str, Any] 

1161 if parsed_data is not None: 

1162 body = parsed_data 

1163 else: 

1164 assert prompt is not None 

1165 if "generate" in api_path: 

1166 # SGLang native format: the prompt travels as ``text`` and the 

1167 # decoding controls under ``sampling_params``. 

1168 body = { 

1169 "text": prompt, 

1170 "sampling_params": {"max_new_tokens": max_tokens}, 

1171 "stream": stream_response, 

1172 } 

1173 elif "/v2/" in api_path: 

1174 # A KServe v2 (Triton) path takes tensors; a prompt can't 

1175 # become an inference request. 

1176 formatter.print_error(_TRITON_INVOKE_HINT) 

1177 sys.exit(1) 

1178 else: 

1179 # OpenAI-compatible (vLLM, etc.) 

1180 # Determine model name for OpenAI-compatible request 

1181 model_name = endpoint_name 

1182 if isinstance(spec, dict): 

1183 is_vllm = persisted_framework == "vllm" or "vllm" in image_lower 

1184 model_name = _openai_request_model(spec, vllm=is_vllm) or endpoint_name 

1185 # Default for vLLM with no explicit model — auto-detect 

1186 # by querying /v1/models on the running endpoint 

1187 if model_name == endpoint_name and is_vllm: 

1188 try: 

1189 detect_client = get_aws_client(config) 

1190 models_path = f"/inference/{endpoint_name}/v1/models" 

1191 models_resp = detect_client.make_authenticated_request( 

1192 method="GET", 

1193 path=models_path, 

1194 target_region=region, 

1195 ) 

1196 if models_resp.ok: 

1197 models_data = models_resp.json().get("data", []) 

1198 if models_data: 

1199 model_name = models_data[0]["id"] 

1200 except Exception: # nosec B110 # discovery is best-effort 

1201 pass # Fall through to endpoint_name as model 

1202 body = {"model": model_name, "max_tokens": max_tokens, "stream": stream_response} 

1203 if api_path.rstrip("/").endswith("/chat/completions"): 

1204 # The chat API takes messages, not a prompt: the prompt 

1205 # becomes a single user turn. 

1206 body["messages"] = [{"role": "user", "content": prompt}] 

1207 else: 

1208 body["prompt"] = prompt 

1209 

1210 # The OpenAI-compatible model name this request asks for, if any. A 

1211 # 404 for it is checked against what the endpoint actually serves. 

1212 requested_model = body.get("model") if resolved_framework in ("vllm", "openai") else None 

1213 

1214 def _report_http_error() -> None: 

1215 formatter.print_error(f"HTTP {response.status_code}: {response.text[:500]}") 

1216 if response.status_code == 404 and isinstance(requested_model, str): 

1217 hint = _served_model_hint( 

1218 client, 

1219 endpoint_name=endpoint_name, 

1220 endpoint_path=endpoint_path, 

1221 requested_model=requested_model, 

1222 region=region, 

1223 spec=spec, 

1224 ) 

1225 if hint: 

1226 formatter.print_info(hint) 

1227 sys.exit(1) 

1228 

1229 if stream_response: 

1230 # Keep streamed stdout byte-for-byte pipeline-friendly; request 

1231 # metadata belongs on stderr when the response itself is streamed. 

1232 print(f"ℹ POST {full_path}", file=sys.stderr) 

1233 else: 

1234 formatter.print_info(f"POST {full_path}") 

1235 

1236 # Make the authenticated request. ``stream=True`` prevents requests 

1237 # from preloading the body so chunks can reach stdout as they arrive. 

1238 client = get_aws_client(config) 

1239 response = client.make_authenticated_request( 

1240 method="POST", 

1241 path=full_path, 

1242 body=body, 

1243 target_region=region, 

1244 stream=stream_response, 

1245 ) 

1246 

1247 if stream_response: 

1248 try: 

1249 if not response.ok: 

1250 _report_http_error() 

1251 

1252 # Requests assumes ISO-8859-1 for text/* without a declared 

1253 # charset. Model token streams are UTF-8 in practice, so honor 

1254 # only an explicit response charset and otherwise use UTF-8. 

1255 content_type = response.headers.get("content-type", "") 

1256 encoding = "utf-8" 

1257 if isinstance(content_type, str): 

1258 parsed_content_type = Message() 

1259 parsed_content_type["content-type"] = content_type 

1260 declared_charset = parsed_content_type.get_content_charset() 

1261 if declared_charset is not None: 

1262 try: 

1263 codecs.lookup(declared_charset) 

1264 except LookupError: 

1265 pass 

1266 else: 

1267 encoding = declared_charset 

1268 

1269 decoder = codecs.getincrementaldecoder(encoding)(errors="replace") 

1270 for chunk in response.iter_content(chunk_size=8192, decode_unicode=False): 

1271 if not chunk: 

1272 continue 

1273 output = chunk if isinstance(chunk, str) else decoder.decode(chunk) 

1274 if output: 

1275 sys.stdout.write(output) 

1276 sys.stdout.flush() 

1277 remainder = decoder.decode(b"", final=True) 

1278 if remainder: 

1279 sys.stdout.write(remainder) 

1280 sys.stdout.flush() 

1281 finally: 

1282 response.close() 

1283 return 

1284 

1285 # Buffered responses retain the friendly extraction used by the CLI. 

1286 if response.ok: 

1287 try: 

1288 resp_json = response.json() 

1289 # Extract the generated text for common formats 

1290 text = None 

1291 if "choices" in resp_json: 

1292 # OpenAI format 

1293 choices = resp_json["choices"] 

1294 if choices: 

1295 text = choices[0].get("text") or choices[0].get("message", {}).get( 

1296 "content" 

1297 ) 

1298 elif isinstance(resp_json, dict) and isinstance(resp_json.get("text"), str): 

1299 # SGLang native /generate format 

1300 text = resp_json["text"] 

1301 

1302 if text and config.output_format == "table": 

1303 print(f"\n{text.strip()}\n") 

1304 else: 

1305 emit_structured_document( 

1306 resp_json, 

1307 output_format="json", 

1308 rendered=_json.dumps(resp_json, indent=2), 

1309 ) 

1310 except _json.JSONDecodeError: 

1311 print(response.text) 

1312 else: 

1313 _report_http_error() 

1314 

1315 except Exception as e: 

1316 formatter.print_error(f"Failed to invoke endpoint: {e}") 

1317 sys.exit(1) 

1318 

1319 

1320@inference.command("canary") 

1321@click.argument("endpoint_name") 

1322@click.option("--image", "-i", required=True, help="New container image for canary") 

1323@click.option( 

1324 "--weight", 

1325 "-w", 

1326 default=10, 

1327 type=int, 

1328 help="Percentage of traffic to canary (1-99, default: 10)", 

1329) 

1330@click.option( 

1331 "--replicas", "-r", default=1, type=int, help="Number of canary replicas (default: 1)" 

1332) 

1333@pass_config 

1334def inference_canary( 

1335 config: Any, endpoint_name: Any, image: Any, weight: Any, replicas: Any 

1336) -> None: 

1337 """Start a canary deployment with a new image. 

1338 

1339 Routes a percentage of traffic to the canary while the primary 

1340 continues serving the rest. Use 'promote' to make the canary 

1341 the new primary, or 'rollback' to remove it. 

1342 

1343 Examples: 

1344 gco inference canary my-llm -i vllm/vllm-openai:v0.32.0 --weight 10 

1345 gco inference canary my-llm -i new-image:latest -w 25 -r 2 

1346 """ 

1347 from ..inference import get_inference_manager 

1348 

1349 formatter = get_output_formatter(config) 

1350 

1351 try: 

1352 manager = get_inference_manager(config) 

1353 result = manager.canary_deploy(endpoint_name, image, weight=weight, replicas=replicas) 

1354 

1355 if not result: 

1356 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

1357 sys.exit(1) 

1358 

1359 formatter.print_success( 

1360 f"Canary started: {weight}% traffic → {image} ({replicas} replica(s))" 

1361 ) 

1362 formatter.print_info(f"Monitor with: gco inference status {endpoint_name}") 

1363 formatter.print_info(f"Promote with: gco inference promote {endpoint_name}") 

1364 formatter.print_info(f"Rollback with: gco inference rollback {endpoint_name}") 

1365 

1366 except ValueError as e: 

1367 formatter.print_error(str(e)) 

1368 sys.exit(1) 

1369 except Exception as e: 

1370 formatter.print_error(f"Failed to start canary: {e}") 

1371 sys.exit(1) 

1372 

1373 

1374@inference.command("promote") 

1375@click.argument("endpoint_name") 

1376@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1377@pass_config 

1378def inference_promote(config: Any, endpoint_name: Any, yes: Any) -> None: 

1379 """Promote the canary to primary. 

1380 

1381 Replaces the primary image with the canary image and removes 

1382 the canary deployment. All traffic goes to the new image. 

1383 

1384 Examples: 

1385 gco inference promote my-llm -y 

1386 """ 

1387 from ..inference import get_inference_manager 

1388 

1389 formatter = get_output_formatter(config) 

1390 

1391 try: 

1392 manager = get_inference_manager(config) 

1393 endpoint = manager.get_endpoint(endpoint_name) 

1394 

1395 if not endpoint: 

1396 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

1397 sys.exit(1) 

1398 

1399 canary = endpoint.get("spec", {}).get("canary") 

1400 if not canary: 

1401 formatter.print_error(f"Endpoint '{endpoint_name}' has no active canary") 

1402 sys.exit(1) 

1403 

1404 if not yes: 

1405 current_image = endpoint.get("spec", {}).get("image", "unknown") 

1406 interactive_echo(f" Current primary: {current_image}") 

1407 interactive_echo(f" Canary image: {canary.get('image', 'unknown')}") 

1408 interactive_echo(f" Canary weight: {canary.get('weight', 0)}%") 

1409 if not confirm(" Promote canary to primary?"): 

1410 formatter.print_info("Cancelled") 

1411 return 

1412 

1413 result = manager.promote_canary(endpoint_name) 

1414 if result: 

1415 new_image = result.get("spec", {}).get("image", "unknown") 

1416 formatter.print_success(f"Promoted: all traffic now serving {new_image}") 

1417 else: 

1418 formatter.print_error("Promotion failed") 

1419 sys.exit(1) 

1420 

1421 except ValueError as e: 

1422 formatter.print_error(str(e)) 

1423 sys.exit(1) 

1424 except Exception as e: 

1425 formatter.print_error(f"Failed to promote canary: {e}") 

1426 sys.exit(1) 

1427 

1428 

1429@inference.command("rollback") 

1430@click.argument("endpoint_name") 

1431@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1432@pass_config 

1433def inference_rollback(config: Any, endpoint_name: Any, yes: Any) -> None: 

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

1435 

1436 All traffic returns to the primary deployment. 

1437 

1438 Examples: 

1439 gco inference rollback my-llm -y 

1440 """ 

1441 from ..inference import get_inference_manager 

1442 

1443 formatter = get_output_formatter(config) 

1444 

1445 try: 

1446 manager = get_inference_manager(config) 

1447 endpoint = manager.get_endpoint(endpoint_name) 

1448 

1449 if not endpoint: 

1450 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

1451 sys.exit(1) 

1452 

1453 canary = endpoint.get("spec", {}).get("canary") 

1454 if not canary: 

1455 formatter.print_error(f"Endpoint '{endpoint_name}' has no active canary") 

1456 sys.exit(1) 

1457 

1458 if not yes: 

1459 interactive_echo(f" Canary image: {canary.get('image', 'unknown')}") 

1460 interactive_echo(f" Canary weight: {canary.get('weight', 0)}%") 

1461 if not confirm(" Remove canary and restore full traffic to primary?"): 

1462 formatter.print_info("Cancelled") 

1463 return 

1464 

1465 result = manager.rollback_canary(endpoint_name) 

1466 if result: 

1467 primary_image = result.get("spec", {}).get("image", "unknown") 

1468 formatter.print_success(f"Rolled back: all traffic now serving {primary_image}") 

1469 else: 

1470 formatter.print_error("Rollback failed") 

1471 sys.exit(1) 

1472 

1473 except ValueError as e: 

1474 formatter.print_error(str(e)) 

1475 sys.exit(1) 

1476 except Exception as e: 

1477 formatter.print_error(f"Failed to rollback canary: {e}") 

1478 sys.exit(1) 

1479 

1480 

1481@inference.command("health") 

1482@click.argument("endpoint_name") 

1483@click.option("--region", "-r", help="Target region to check") 

1484@pass_config 

1485def inference_health(config: Any, endpoint_name: Any, region: Any) -> None: 

1486 """Check if an inference endpoint is healthy and ready to serve. 

1487 

1488 Hits the endpoint's health check path and reports status and latency. 

1489 

1490 Examples: 

1491 gco inference health my-llm 

1492 

1493 gco inference health my-llm -r us-east-1 

1494 """ 

1495 import time as _time 

1496 

1497 from ..aws_client import get_aws_client 

1498 from ..inference import get_inference_manager 

1499 

1500 formatter = get_output_formatter(config) 

1501 

1502 try: 

1503 manager = get_inference_manager(config) 

1504 endpoint = manager.get_endpoint(endpoint_name) 

1505 if not endpoint: 

1506 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

1507 sys.exit(1) 

1508 

1509 endpoint_path = endpoint.get("ingress_path", f"/inference/{endpoint_name}") 

1510 spec = endpoint.get("spec", {}) 

1511 health_path = ( 

1512 spec.get("health_check_path", "/health") if isinstance(spec, dict) else "/health" 

1513 ) 

1514 full_path = f"{endpoint_path}{health_path}" 

1515 

1516 client = get_aws_client(config) 

1517 start = _time.monotonic() 

1518 response = client.make_authenticated_request( 

1519 method="GET", 

1520 path=full_path, 

1521 target_region=region, 

1522 ) 

1523 latency_ms = (_time.monotonic() - start) * 1000 

1524 

1525 result = { 

1526 "endpoint": endpoint_name, 

1527 "status": "healthy" if response.ok else "unhealthy", 

1528 "http_status": response.status_code, 

1529 "latency_ms": round(latency_ms, 1), 

1530 "path": full_path, 

1531 } 

1532 

1533 try: 

1534 result["body"] = response.json() 

1535 except Exception: 

1536 result["body"] = response.text[:200] if response.text else None 

1537 

1538 if config.output_format == "table": 

1539 status_icon = "✓" if response.ok else "✗" 

1540 formatter.print_info( 

1541 f"{status_icon} {endpoint_name}: {result['status']} " 

1542 f"(HTTP {response.status_code}, {result['latency_ms']}ms)" 

1543 ) 

1544 else: 

1545 formatter.print(result) 

1546 

1547 except Exception as e: 

1548 formatter.print_error(f"Health check failed: {e}") 

1549 sys.exit(1) 

1550 

1551 

1552@inference.command("models") 

1553@click.argument("endpoint_name") 

1554@click.option( 

1555 "--framework", 

1556 type=click.Choice(["vllm", "sglang"]), 

1557 default=None, 

1558 help="Runtime metadata contract (default: persisted endpoint framework or vLLM).", 

1559) 

1560@click.option("--region", "-r", help="Target region to query") 

1561@pass_config 

1562def inference_models( 

1563 config: Any, 

1564 endpoint_name: Any, 

1565 framework: Any, 

1566 region: Any, 

1567) -> None: 

1568 """Read exact model identity from vLLM /v1/models or SGLang /server_info. 

1569 

1570 vLLM's OpenAI inventory is printed as returned, and so is a Triton 

1571 endpoint's /v2/models/stats, which names every loaded model and version 

1572 (Triton has no model list of its own). For SGLang the command 

1573 reads ``/server_info`` (the launcher arguments the running server 

1574 resolved) and prints only the identity fields — ``model_path``, 

1575 ``served_model_name``, ``revision``, ``tokenizer_path`` and ``version`` — 

1576 so the exact model *and* the exact revision the process was started with 

1577 are visible without the multi-kilobyte scheduler state that endpoint also 

1578 carries. 

1579 """ 

1580 import json as _json 

1581 

1582 from ..aws_client import get_aws_client 

1583 from ..inference import get_inference_manager 

1584 

1585 formatter = get_output_formatter(config) 

1586 

1587 try: 

1588 manager = get_inference_manager(config) 

1589 endpoint = manager.get_endpoint(endpoint_name) 

1590 if not endpoint: 

1591 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

1592 sys.exit(1) 

1593 

1594 ingress_path = endpoint.get("ingress_path", f"/inference/{endpoint_name}") 

1595 spec = endpoint.get("spec") 

1596 persisted_framework = spec.get("framework") if isinstance(spec, dict) else None 

1597 image = spec.get("image") if isinstance(spec, dict) else None 

1598 image_lower = image.lower() if isinstance(image, str) else "" 

1599 if persisted_framework is not None and persisted_framework not in ("vllm", "sglang"): 

1600 raise ValueError("endpoint has an unsupported persisted inference framework") 

1601 selected_framework = framework or _resolve_invoke_framework( 

1602 persisted_framework, image_lower 

1603 ) 

1604 if selected_framework not in ("vllm", "sglang", "triton"): 

1605 # Unknown OpenAI-compatible images publish no model identity 

1606 # document this command knows how to read; the OpenAI inventory is 

1607 # the closest thing to one. 

1608 selected_framework = "vllm" 

1609 # Triton has no model list; its statistics extension reports every 

1610 # loaded model by name and version, which is the inventory we need. 

1611 model_path = { 

1612 "vllm": "v1/models", 

1613 "sglang": "server_info", 

1614 "triton": "v2/models/stats", 

1615 }[selected_framework] 

1616 full_path = f"{ingress_path}/{model_path}" 

1617 

1618 client = get_aws_client(config) 

1619 response = client.make_authenticated_request( 

1620 method="GET", 

1621 path=full_path, 

1622 target_region=region, 

1623 ) 

1624 

1625 if response.ok: 

1626 try: 

1627 resp_json = response.json() 

1628 if selected_framework == "sglang": 

1629 if not isinstance(resp_json, dict): 

1630 raise ValueError("SGLang /server_info did not return a JSON object") 

1631 resp_json = { 

1632 key: resp_json[key] 

1633 for key in _SGLANG_SERVER_INFO_IDENTITY_KEYS 

1634 if key in resp_json 

1635 } 

1636 emit_structured_document( 

1637 resp_json, 

1638 output_format="json", 

1639 rendered=_json.dumps(resp_json, indent=2), 

1640 ) 

1641 except _json.JSONDecodeError: 

1642 print(response.text) 

1643 else: 

1644 formatter.print_error(f"HTTP {response.status_code}: {response.text[:500]}") 

1645 sys.exit(1) 

1646 

1647 except Exception as e: 

1648 formatter.print_error(f"Failed to list models: {e}") 

1649 sys.exit(1) 

1650 

1651 

1652@inference.command("set-topology") 

1653@click.argument("endpoint_name") 

1654@click.option( 

1655 "--prefill", 

1656 required=True, 

1657 type=int, 

1658 help="Prefill (X) instance count for the XpYd topology.", 

1659) 

1660@click.option( 

1661 "--decode", 

1662 required=True, 

1663 type=int, 

1664 help="Decode (Y) instance count for the XpYd topology.", 

1665) 

1666@pass_config 

1667def inference_set_topology(config: Any, endpoint_name: Any, prefill: Any, decode: Any) -> None: 

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

1669 

1670 Updates the endpoint's prefill (X) and decode (Y) instance counts and 

1671 re-triggers reconciliation so each region's monitor adjusts the role 

1672 replica counts. Both counts must be integers in the range 1..1000. 

1673 

1674 Examples: 

1675 gco inference set-topology llama-pd --prefill 3 --decode 2 

1676 """ 

1677 from ..inference import get_inference_manager 

1678 

1679 formatter = get_output_formatter(config) 

1680 

1681 try: 

1682 manager = get_inference_manager(config) 

1683 result = manager.set_topology(endpoint_name, prefill, decode) 

1684 

1685 if result: 

1686 formatter.print_success( 

1687 f"Endpoint '{endpoint_name}' topology set to {prefill}p{decode}d" 

1688 ) 

1689 formatter.print_info( 

1690 "The inference_monitor will adjust prefill and decode " 

1691 "replica counts in each region." 

1692 ) 

1693 if config.output_format != "table": 

1694 formatter.print(result) 

1695 else: 

1696 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

1697 sys.exit(1) 

1698 

1699 except ValueError as e: 

1700 formatter.print_error(str(e)) 

1701 sys.exit(1) 

1702 except Exception as e: 

1703 formatter.print_error(f"Failed to set topology: {e}") 

1704 sys.exit(1) 

1705 

1706 

1707@inference.command("configure-store") 

1708@click.argument("endpoint_name") 

1709@click.option( 

1710 "--cold-tier/--no-cold-tier", 

1711 "cold_tier", 

1712 default=None, 

1713 help="Opt the endpoint into (or out of) the asynchronous S3 cold tier. " 

1714 "Enabling it also enables the shared store it extends.", 

1715) 

1716@click.option( 

1717 "--offload", 

1718 type=click.Choice(["cpu", "disk", "none"]), 

1719 default=None, 

1720 help="KV-store offload tier for spilling cache beyond GPU memory.", 

1721) 

1722@click.option( 

1723 "--global-segment-size", 

1724 type=int, 

1725 default=None, 

1726 help="Global segment size in bytes for the KV-cache store.", 

1727) 

1728@click.option( 

1729 "--local-buffer-size", 

1730 type=int, 

1731 default=None, 

1732 help="Local buffer size in bytes for the KV-cache store.", 

1733) 

1734@click.option( 

1735 "--enable-store/--disable-store", 

1736 "enabled", 

1737 default=None, 

1738 help="Enable or disable the shared KV-cache store.", 

1739) 

1740@pass_config 

1741def inference_configure_store( 

1742 config: Any, 

1743 endpoint_name: Any, 

1744 cold_tier: Any, 

1745 offload: Any, 

1746 global_segment_size: Any, 

1747 local_buffer_size: Any, 

1748 enabled: Any, 

1749) -> None: 

1750 """Update the shared KV-cache store on a Mooncake endpoint. 

1751 

1752 Merges the given settings into the endpoint's existing KV-cache store 

1753 configuration and re-triggers reconciliation so each region's monitor picks 

1754 up the change. Enabling the cold tier also enables the shared store it 

1755 extends. Use 'gco inference populate-kv' to pre-warm the cold tier. 

1756 

1757 Examples: 

1758 gco inference configure-store my-llm --cold-tier 

1759 gco inference configure-store my-llm --offload cpu --local-buffer-size 2147483648 

1760 """ 

1761 from ..inference import get_inference_manager 

1762 

1763 formatter = get_output_formatter(config) 

1764 

1765 try: 

1766 manager = get_inference_manager(config) 

1767 endpoint = manager.get_endpoint(endpoint_name) 

1768 if not endpoint: 

1769 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

1770 sys.exit(1) 

1771 

1772 # Merge onto the endpoint's current store block so changing one field 

1773 # does not drop the others. 

1774 spec = endpoint.get("spec", {}) if isinstance(endpoint, dict) else {} 

1775 mooncake = spec.get("mooncake", {}) if isinstance(spec, dict) else {} 

1776 store_config = dict(mooncake.get("store") or {}) 

1777 

1778 if enabled is not None: 

1779 store_config["enabled"] = enabled 

1780 if cold_tier is not None: 

1781 store_config["cold_tier_enabled"] = cold_tier 

1782 if cold_tier: 

1783 # The cold tier extends the shared store, so enabling it enables 

1784 # the store too. 

1785 store_config["enabled"] = True 

1786 if offload is not None: 

1787 store_config["offload"] = offload 

1788 if global_segment_size is not None: 

1789 store_config["global_segment_size"] = global_segment_size 

1790 if local_buffer_size is not None: 

1791 store_config["local_buffer_size"] = local_buffer_size 

1792 

1793 if not store_config: 

1794 formatter.print_error( 

1795 "No store settings given. Pass --cold-tier, --offload, " 

1796 "--global-segment-size, --local-buffer-size, or --enable-store." 

1797 ) 

1798 sys.exit(1) 

1799 

1800 result = manager.configure_store(endpoint_name, store_config) 

1801 if result: 

1802 formatter.print_success(f"Endpoint '{endpoint_name}' store configuration updated") 

1803 formatter.print_info( 

1804 "The inference_monitor will re-render the KV-cache store " 

1805 "configuration in each region and roll the role pods onto it." 

1806 ) 

1807 if config.output_format != "table": 

1808 formatter.print(result) 

1809 else: 

1810 formatter.print_error(f"Endpoint '{endpoint_name}' not found") 

1811 sys.exit(1) 

1812 

1813 except ValueError as e: 

1814 formatter.print_error(str(e)) 

1815 sys.exit(1) 

1816 except Exception as e: 

1817 formatter.print_error(f"Failed to configure store: {e}") 

1818 sys.exit(1) 

1819 

1820 

1821@inference.command("populate-kv") 

1822@click.argument("endpoint_name") 

1823@click.argument("local_path") 

1824@click.option( 

1825 "--region", 

1826 "-r", 

1827 required=True, 

1828 help="Region whose general-purpose bucket backs the endpoint's KV-cache cold tier.", 

1829) 

1830@pass_config 

1831def inference_populate_kv(config: Any, endpoint_name: Any, local_path: Any, region: Any) -> None: 

1832 """Upload data into an endpoint's Mooncake KV-cache cold tier. 

1833 

1834 Uploads a local file or directory to the region's general-purpose bucket 

1835 under the cold-tier key prefix the endpoint reads from 

1836 (mooncake-kv/<endpoint>/). The endpoint must be deployed with the cold tier 

1837 enabled (deploy with --mooncake-cold-tier, or run 

1838 'gco inference configure-store <name> --cold-tier') for its pods to read the 

1839 uploaded data. 

1840 

1841 Examples: 

1842 gco inference populate-kv my-llm ./kv-warm-set/ --region us-east-1 

1843 """ 

1844 from ..models import get_regional_bucket_manager 

1845 

1846 formatter = get_output_formatter(config) 

1847 

1848 try: 

1849 manager = get_regional_bucket_manager(config) 

1850 formatter.print_info( 

1851 f"Uploading {local_path} into the KV-cache cold tier for " 

1852 f"'{endpoint_name}' in '{region}'..." 

1853 ) 

1854 result = manager.populate_kv_cache(local_path, region, endpoint_name) 

1855 

1856 formatter.print_success( 

1857 f"Uploaded {result['files_uploaded']} file(s) to {result['s3_uri']}" 

1858 ) 

1859 formatter.print_info( 

1860 "Pods for this endpoint read the cold tier when it is enabled " 

1861 "(deploy with --mooncake-cold-tier or 'gco inference configure-store')." 

1862 ) 

1863 

1864 if config.output_format != "table": 

1865 formatter.print(result) 

1866 

1867 except Exception as e: 

1868 formatter.print_error(f"Failed to populate KV cache: {e}") 

1869 sys.exit(1)