Coverage for gco_mcp / tools / inference.py: 100.00%

177 statements  

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

1"""Inference endpoint management MCP tools.""" 

2 

3import asyncio 

4import contextlib 

5import json 

6from typing import Any 

7 

8import cli_runner 

9from audit import audit_logged 

10from feature_flags import FLAG_DESTRUCTIVE_OPERATIONS, is_enabled 

11from server import mcp 

12 

13 

14async def _ctx_warning(message: str) -> None: 

15 """Emit ``ctx.warning(...)`` from inside a tool body, no-op when no Context. 

16 

17 The destructive ``delete_inference`` tool runs short — we don't need the 

18 full long-task progress stack, just an audited warning back to the 

19 operator (and the audit log via the middleware spy). 

20 """ 

21 try: 

22 from fastmcp.server.dependencies import get_context 

23 

24 ctx = get_context() 

25 except Exception: 

26 return 

27 with contextlib.suppress(Exception): 

28 await ctx.warning(message) 

29 

30 

31#: Serving modes that split an endpoint into separate prefill and decode roles. 

32#: These endpoints carry extra Kubernetes resources (per-role Deployments, a 

33#: proxy, and a shared store dependency), so removing one is held behind the 

34#: destructive-operations flag. 

35_DISAGGREGATED_MODES = frozenset({"disaggregated", "both"}) 

36 

37 

38def _endpoint_is_disaggregated(name: str) -> bool: 

39 """Return True when the named endpoint runs a split prefill/decode topology. 

40 

41 Reads the endpoint's persisted spec (read-only) and inspects its 

42 ``mooncake`` block. A ``mode`` of ``disaggregated`` or ``both`` runs 

43 separate prefill and decode roles. Anything else — a plain 

44 single-Deployment endpoint, an endpoint without a ``mooncake`` block, or a 

45 lookup that cannot be parsed — is treated as not disaggregated. 

46 """ 

47 raw = cli_runner._run_cli("inference", "status", name) 

48 try: 

49 data = json.loads(raw) 

50 except TypeError, ValueError: 

51 return False 

52 if not isinstance(data, dict): 

53 return False 

54 spec = data.get("spec") 

55 if not isinstance(spec, dict): 

56 return False 

57 mooncake = spec.get("mooncake") 

58 if not isinstance(mooncake, dict): 

59 return False 

60 return mooncake.get("mode") in _DISAGGREGATED_MODES 

61 

62 

63@mcp.tool(tags={"low-risk", "inference"}) 

64@audit_logged 

65def deploy_inference( 

66 name: str, 

67 image: str, 

68 gpu_count: int = 1, 

69 replicas: int = 1, 

70 port: int = 8000, 

71 region: str | None = None, 

72 env_vars: list[str] | None = None, 

73 framework: str | None = None, 

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

75) -> str: 

76 """Deploy an inference endpoint across regions. 

77 

78 The model to serve is ``MODEL=<id-or-path>`` in ``env_vars``: vLLM and 

79 SGLang both take it from there. Servers without a strict adapter, such as 

80 Triton, need their command in ``extra_args`` (for Triton, 

81 ``["tritonserver", "--model-repository=/models/<name>"]``). 

82 

83 Args: 

84 name: Endpoint name (e.g. my-llm). 

85 image: Container image (e.g. vllm/vllm-openai:v0.32.0). 

86 gpu_count: GPUs per replica. 

87 replicas: Number of replicas per region. 

88 port: Container port. 

89 region: Target region(s). Omit for all deployed regions. 

90 env_vars: Environment variables as KEY=VALUE strings. 

91 framework: Strict serving adapter, ``vllm`` or ``sglang``. Omit to let 

92 the image name decide. 

93 extra_args: Container arguments, one token per entry, appended in order. 

94 """ 

95 args = [ 

96 "inference", 

97 "deploy", 

98 name, 

99 "-i", 

100 image, 

101 "--gpu-count", 

102 str(gpu_count), 

103 "--replicas", 

104 str(replicas), 

105 "--port", 

106 str(port), 

107 ] 

108 if region: 

109 args += ["-r", region] 

110 for env in env_vars or []: 

111 args += ["-e", env] 

112 if framework: 

113 args += ["--framework", framework] 

114 # The ``--extra-args=<token>`` form keeps a token that starts with "--" 

115 # from being read as an option of its own. 

116 args += [f"--extra-args={token}" for token in extra_args or []] 

117 return cli_runner._run_cli(*args) 

118 

119 

120@mcp.tool(tags={"safe", "inference"}) 

121@audit_logged 

122def list_inference_endpoints(state: str | None = None, region: str | None = None) -> str: 

123 """List all inference endpoints. 

124 

125 Args: 

126 state: Filter by state (deploying, running, stopped, deleted). 

127 region: Filter by region. 

128 """ 

129 args = ["inference", "list"] 

130 if state: 

131 args += ["--state", state] 

132 if region: 

133 args += ["-r", region] 

134 return cli_runner._run_cli(*args) 

135 

136 

137@mcp.tool(tags={"safe", "inference"}) 

138@audit_logged 

139def inference_status(name: str) -> str: 

140 """Get detailed status of an inference endpoint including per-region breakdown. 

141 

142 Args: 

143 name: Endpoint name. 

144 """ 

145 return cli_runner._run_cli("inference", "status", name) 

146 

147 

148@mcp.tool(tags={"low-risk", "inference"}) 

149@audit_logged 

150def scale_inference(name: str, replicas: int) -> str: 

151 """Scale an inference endpoint. 

152 

153 Args: 

154 name: Endpoint name. 

155 replicas: Target replica count. 

156 """ 

157 return cli_runner._run_cli("inference", "scale", name, "--replicas", str(replicas)) 

158 

159 

160@mcp.tool(tags={"low-risk", "inference"}) 

161@audit_logged 

162def update_inference_image(name: str, image: str) -> str: 

163 """Rolling update of an inference endpoint's container image. 

164 

165 Args: 

166 name: Endpoint name. 

167 image: New container image. 

168 """ 

169 return cli_runner._run_cli("inference", "update-image", name, "-i", image) 

170 

171 

172@mcp.tool(tags={"low-risk", "inference"}) 

173@audit_logged 

174def stop_inference(name: str) -> str: 

175 """Stop an inference endpoint (scales to zero, keeps config). 

176 

177 Args: 

178 name: Endpoint name. 

179 """ 

180 return cli_runner._run_cli("inference", "stop", name, "-y") 

181 

182 

183@mcp.tool(tags={"low-risk", "inference"}) 

184@audit_logged 

185def start_inference(name: str) -> str: 

186 """Start a stopped inference endpoint. 

187 

188 Args: 

189 name: Endpoint name. 

190 """ 

191 return cli_runner._run_cli("inference", "start", name) 

192 

193 

194@mcp.tool(tags={"low-risk", "inference"}) 

195@audit_logged 

196def add_inference_region(name: str, region: str) -> str: 

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

198 

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

200 to the deployment later does not run it until it is added here. The 

201 inference monitor in that Region then creates the endpoint from its stored 

202 spec. The Region must already run a GCO regional stack. 

203 

204 Args: 

205 name: Endpoint name. 

206 region: Deployed GCO Region to add (e.g. ``us-west-2``). 

207 """ 

208 return cli_runner._run_cli("inference", "add-region", name, region) 

209 

210 

211@mcp.tool(tags={"low-risk", "inference"}) 

212@audit_logged 

213def remove_inference_region(name: str, region: str) -> str: 

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

215 

216 The inference monitor in that Region deletes the endpoint's resources 

217 there; the endpoint keeps serving from its other Regions and the Region can 

218 be added back. Removing the endpoint's last Region is refused. 

219 

220 Args: 

221 name: Endpoint name. 

222 region: Region to remove. 

223 """ 

224 return cli_runner._run_cli("inference", "remove-region", name, region, "-y") 

225 

226 

227@mcp.tool(tags={"low-risk", "inference"}) 

228@audit_logged 

229def deploy_disaggregated_inference( 

230 name: str, 

231 image: str | None = None, 

232 prefill: int = 1, 

233 decode: int = 1, 

234 mooncake_mode: str = "disaggregated", 

235 gpu_count: int = 1, 

236 port: int = 8000, 

237 region: str | None = None, 

238 env_vars: list[str] | None = None, 

239) -> str: 

240 """Deploy a split prefill/decode (XpYd) inference endpoint. 

241 

242 Registers an endpoint that serves the prefill and decode phases on 

243 separate roles backed by a shared KV-cache store. When no image is 

244 supplied the deploy falls back to the default upstream Mooncake-enabled 

245 vLLM image. 

246 

247 Args: 

248 name: Endpoint name (e.g. llama-pd). 

249 image: Container image. Omit to use the default upstream 

250 Mooncake-enabled vLLM image. 

251 prefill: Prefill (X) instance count for the topology. 

252 decode: Decode (Y) instance count for the topology. 

253 mooncake_mode: Serving mode: disaggregated, store, or both. 

254 gpu_count: GPUs per replica. 

255 port: Container port. 

256 region: Target region(s). Omit for all deployed regions. 

257 env_vars: Environment variables as KEY=VALUE strings. 

258 """ 

259 args = [ 

260 "inference", 

261 "deploy", 

262 name, 

263 "--mooncake-mode", 

264 mooncake_mode, 

265 "--prefill-replicas", 

266 str(prefill), 

267 "--decode-replicas", 

268 str(decode), 

269 "--gpu-count", 

270 str(gpu_count), 

271 "--port", 

272 str(port), 

273 ] 

274 if image: 

275 args += ["-i", image] 

276 if region: 

277 args += ["-r", region] 

278 for env in env_vars or []: 

279 args += ["-e", env] 

280 return cli_runner._run_cli(*args) 

281 

282 

283@mcp.tool(tags={"low-risk", "inference"}) 

284@audit_logged 

285def set_mooncake_topology(name: str, prefill: int, decode: int) -> str: 

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

287 

288 Updates the prefill (X) and decode (Y) instance counts and re-triggers 

289 reconciliation so each region adjusts its role replica counts. Both 

290 counts must be integers in the range 1..1000. 

291 

292 Args: 

293 name: Endpoint name. 

294 prefill: New prefill (X) instance count. 

295 decode: New decode (Y) instance count. 

296 """ 

297 return cli_runner._run_cli( 

298 "inference", 

299 "set-topology", 

300 name, 

301 "--prefill", 

302 str(prefill), 

303 "--decode", 

304 str(decode), 

305 ) 

306 

307 

308@mcp.tool(tags={"low-risk", "inference"}) 

309@audit_logged 

310def configure_mooncake_store( 

311 endpoint_name: str, 

312 cold_tier: bool | None = None, 

313 offload: str | None = None, 

314 global_segment_size: int | None = None, 

315 local_buffer_size: int | None = None, 

316 enabled: bool | None = None, 

317) -> str: 

318 """Update a Mooncake endpoint's shared KV-cache store configuration. 

319 

320 At least one optional setting must be supplied. Enabling the cold tier also 

321 enables the shared store it extends; the CLI merges supplied fields onto 

322 the endpoint's existing store block before reconciliation. 

323 

324 Args: 

325 endpoint_name: Endpoint whose store configuration should change. 

326 cold_tier: Enable or disable the asynchronous per-region S3 cold tier. 

327 offload: Spill tier: ``cpu``, ``disk``, or ``none``. 

328 global_segment_size: Global KV-cache segment size in bytes. 

329 local_buffer_size: Local KV-cache buffer size in bytes. 

330 enabled: Enable or disable the shared store itself. 

331 """ 

332 if all( 

333 value is None 

334 for value in (cold_tier, offload, global_segment_size, local_buffer_size, enabled) 

335 ): 

336 raise ValueError("At least one Mooncake store setting must be supplied") 

337 

338 args = ["inference", "configure-store", endpoint_name] 

339 if cold_tier is not None: 

340 args.append("--cold-tier" if cold_tier else "--no-cold-tier") 

341 if offload is not None: 

342 args += ["--offload", offload] 

343 if global_segment_size is not None: 

344 args += ["--global-segment-size", str(global_segment_size)] 

345 if local_buffer_size is not None: 

346 args += ["--local-buffer-size", str(local_buffer_size)] 

347 if enabled is not None: 

348 args.append("--enable-store" if enabled else "--disable-store") 

349 return cli_runner._run_cli(*args) 

350 

351 

352@mcp.tool(tags={"safe", "inference"}) 

353@audit_logged 

354def mooncake_topology_status(name: str) -> str: 

355 """Show a disaggregated endpoint's per-role topology status. 

356 

357 Returns the endpoint record including its role-keyed region status, so 

358 the prefill and decode roles can be inspected per region. 

359 

360 Args: 

361 name: Endpoint name. 

362 """ 

363 return cli_runner._run_cli("inference", "status", name) 

364 

365 

366@mcp.tool(tags={"low-risk", "inference"}) 

367@audit_logged 

368async def populate_kv_cache(endpoint_name: str, local_path: str, region: str) -> str: 

369 """`gco inference populate-kv` — upload data into an endpoint's KV-cache cold tier. 

370 

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

372 under the cold-tier key prefix the endpoint reads from, pre-warming its 

373 Mooncake KV-cache prefix cache. The endpoint must be deployed with the cold 

374 tier enabled (deploy with --mooncake-cold-tier, or configure it via 

375 configure-store) for its pods to read the uploaded data. 

376 

377 Args: 

378 endpoint_name: Endpoint whose KV-cache cold tier receives the data. 

379 local_path: Local file or directory to upload. 

380 region: Region whose general-purpose bucket backs the cold tier. 

381 """ 

382 return await asyncio.to_thread( 

383 cli_runner._run_cli, 

384 "inference", 

385 "populate-kv", 

386 endpoint_name, 

387 local_path, 

388 "-r", 

389 region, 

390 ) 

391 

392 

393if is_enabled(FLAG_DESTRUCTIVE_OPERATIONS): 

394 

395 @mcp.tool(tags={"destructive", "inference"}) 

396 @audit_logged 

397 async def delete_inference(name: str) -> str: 

398 """[gated by GCO_ENABLE_DESTRUCTIVE_OPERATIONS] destructive. 

399 

400 Delete an inference endpoint. Cannot be undone — the endpoint, its 

401 DynamoDB record, and the underlying Kubernetes resources are removed. 

402 

403 Deleting a disaggregated (split prefill/decode) endpoint is refused at 

404 invocation when GCO_ENABLE_DESTRUCTIVE_OPERATIONS is not enabled: the 

405 request is rejected and no endpoint resource is removed. 

406 

407 Args: 

408 name: Endpoint name. 

409 """ 

410 if not is_enabled(FLAG_DESTRUCTIVE_OPERATIONS) and await asyncio.to_thread( 

411 _endpoint_is_disaggregated, name 

412 ): 

413 message = ( 

414 f"Refusing to delete disaggregated endpoint {name!r}: destructive " 

415 "operations are disabled. Set GCO_ENABLE_DESTRUCTIVE_OPERATIONS=true " 

416 "to allow this deletion." 

417 ) 

418 await _ctx_warning(message) 

419 return json.dumps({"error": message, "destructive_operations_disabled": True}) 

420 await _ctx_warning(f"Deleting inference endpoint {name!r} — this cannot be undone.") 

421 return await asyncio.to_thread(cli_runner._run_cli, "inference", "delete", name, "-y") 

422 

423 

424@mcp.tool(tags={"low-risk", "inference"}) 

425@audit_logged 

426def canary_deploy(name: str, image: str, weight: int = 10) -> str: 

427 """Start a canary deployment (A/B test a new image version). 

428 

429 Args: 

430 name: Endpoint name. 

431 image: New image to canary. 

432 weight: Percentage of traffic to send to canary (1-99). 

433 """ 

434 return cli_runner._run_cli("inference", "canary", name, "-i", image, "--weight", str(weight)) 

435 

436 

437@mcp.tool(tags={"low-risk", "inference"}) 

438@audit_logged 

439def promote_canary(name: str) -> str: 

440 """Promote canary to primary (100% traffic to new version). 

441 

442 Args: 

443 name: Endpoint name. 

444 """ 

445 return cli_runner._run_cli("inference", "promote", name, "-y") 

446 

447 

448@mcp.tool(tags={"low-risk", "inference"}) 

449@audit_logged 

450def rollback_canary(name: str) -> str: 

451 """Rollback canary (remove canary, 100% traffic to primary). 

452 

453 Args: 

454 name: Endpoint name. 

455 """ 

456 return cli_runner._run_cli("inference", "rollback", name, "-y") 

457 

458 

459@mcp.tool(tags={"safe", "inference"}) 

460@audit_logged 

461def invoke_inference( 

462 name: str, 

463 prompt: str, 

464 max_tokens: int = 100, 

465 api_path: str | None = None, 

466 region: str | None = None, 

467) -> str: 

468 """Send a prompt to an inference endpoint and return the generated text. 

469 

470 Automatically discovers the endpoint's ingress path, detects the serving 

471 framework (vLLM or SGLang), and routes the request through the API 

472 Gateway with SigV4 authentication. Triton takes tensors rather than a 

473 prompt, so a Triton endpoint is refused with the --path/--data form to use 

474 through the CLI. 

475 

476 Use this for single-turn text completions. For multi-turn conversations 

477 with chat models, use chat_inference instead. 

478 

479 Args: 

480 name: Endpoint name (e.g. my-llm). 

481 prompt: Text prompt to send to the model. 

482 max_tokens: Maximum tokens to generate (default: 100). 

483 api_path: Override the API sub-path (default: auto-detect from framework). 

484 region: Target region for the request (default: nearest via Global Accelerator). 

485 

486 This MCP tool intentionally returns one buffered string because the MCP 

487 runner has no incremental output contract. The API Gateway/Lambda route 

488 supports response streaming; use ``gco inference invoke --stream`` when 

489 token-by-token output is required. 

490 """ 

491 args = ["inference", "invoke", name, "-p", prompt, "--max-tokens", str(max_tokens)] 

492 if api_path: 

493 args += ["--path", api_path] 

494 if region: 

495 args += ["-r", region] 

496 return cli_runner._run_cli(*args) 

497 

498 

499@mcp.tool(tags={"safe", "inference"}) 

500@audit_logged 

501def chat_inference( 

502 name: str, 

503 messages: list[dict[str, str]], 

504 max_tokens: int = 256, 

505 temperature: float | None = None, 

506 region: str | None = None, 

507) -> str: 

508 """Send a multi-turn chat conversation to an inference endpoint. 

509 

510 Sends an OpenAI-compatible /v1/chat/completions request. Works with 

511 vLLM, SGLang, and any OpenAI-compatible server. 

512 

513 Each message in the list should have 'role' (system/user/assistant) and 

514 'content' keys. 

515 

516 Args: 

517 name: Endpoint name (e.g. my-llm). 

518 messages: List of chat messages, e.g. [{"role": "user", "content": "Hello"}]. 

519 max_tokens: Maximum tokens to generate (default: 256). 

520 temperature: Sampling temperature (optional, server default if omitted). 

521 region: Target region for the request. 

522 

523 This MCP tool intentionally returns one buffered string because the MCP 

524 runner has no incremental output contract. The API Gateway/Lambda route 

525 supports response streaming; use ``gco inference invoke --stream`` when 

526 token-by-token output is required. 

527 """ 

528 body: dict[str, Any] = {"messages": messages, "max_tokens": max_tokens} 

529 if temperature is not None: 

530 body["temperature"] = temperature 

531 data_str = json.dumps(body) 

532 args = ["inference", "invoke", name, "-d", data_str, "--path", "/v1/chat/completions"] 

533 if region: 

534 args += ["-r", region] 

535 return cli_runner._run_cli(*args) 

536 

537 

538@mcp.tool(tags={"safe", "inference"}) 

539@audit_logged 

540def inference_health(name: str, region: str | None = None) -> str: 

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

542 

543 Hits the endpoint's health check path and returns status and latency. 

544 Useful to verify readiness before sending inference requests. 

545 

546 Args: 

547 name: Endpoint name. 

548 region: Target region to check (default: nearest via Global Accelerator). 

549 """ 

550 args = ["inference", "health", name] 

551 if region: 

552 args += ["-r", region] 

553 return cli_runner._run_cli(*args) 

554 

555 

556@mcp.tool(tags={"safe", "inference"}) 

557@audit_logged 

558def list_endpoint_models(name: str, region: str | None = None) -> str: 

559 """List models loaded on an inference endpoint. 

560 

561 Queries the endpoint's /v1/models path (OpenAI-compatible) to discover 

562 which models are loaded, their context length, and other metadata. 

563 Works with vLLM and other OpenAI-compatible servers; SGLang reports its 

564 launcher's model identity, and Triton its /v2/models/stats inventory. 

565 

566 Args: 

567 name: Endpoint name. 

568 region: Target region to query. 

569 """ 

570 args = ["inference", "models", name] 

571 if region: 

572 args += ["-r", region] 

573 return cli_runner._run_cli(*args)