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

166 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +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) -> str: 

74 """Deploy an inference endpoint across regions. 

75 

76 Args: 

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

78 image: Container image (e.g. vllm/vllm-openai:v0.24.0). 

79 gpu_count: GPUs per replica. 

80 replicas: Number of replicas per region. 

81 port: Container port. 

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

83 env_vars: Environment variables as KEY=VALUE strings. 

84 """ 

85 args = [ 

86 "inference", 

87 "deploy", 

88 name, 

89 "-i", 

90 image, 

91 "--gpu-count", 

92 str(gpu_count), 

93 "--replicas", 

94 str(replicas), 

95 "--port", 

96 str(port), 

97 ] 

98 if region: 

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

100 for env in env_vars or []: 

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

102 return cli_runner._run_cli(*args) 

103 

104 

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

106@audit_logged 

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

108 """List all inference endpoints. 

109 

110 Args: 

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

112 region: Filter by region. 

113 """ 

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

115 if state: 115 ↛ 117line 115 didn't jump to line 117 because the condition on line 115 was always true

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

117 if region: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

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

119 return cli_runner._run_cli(*args) 

120 

121 

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

123@audit_logged 

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

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

126 

127 Args: 

128 name: Endpoint name. 

129 """ 

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

131 

132 

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

134@audit_logged 

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

136 """Scale an inference endpoint. 

137 

138 Args: 

139 name: Endpoint name. 

140 replicas: Target replica count. 

141 """ 

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

143 

144 

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

146@audit_logged 

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

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

149 

150 Args: 

151 name: Endpoint name. 

152 image: New container image. 

153 """ 

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

155 

156 

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

158@audit_logged 

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

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

161 

162 Args: 

163 name: Endpoint name. 

164 """ 

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

166 

167 

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

169@audit_logged 

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

171 """Start a stopped inference endpoint. 

172 

173 Args: 

174 name: Endpoint name. 

175 """ 

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

177 

178 

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

180@audit_logged 

181def deploy_disaggregated_inference( 

182 name: str, 

183 image: str | None = None, 

184 prefill: int = 1, 

185 decode: int = 1, 

186 mooncake_mode: str = "disaggregated", 

187 gpu_count: int = 1, 

188 port: int = 8000, 

189 region: str | None = None, 

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

191) -> str: 

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

193 

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

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

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

197 vLLM image. 

198 

199 Args: 

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

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

202 Mooncake-enabled vLLM image. 

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

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

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

206 gpu_count: GPUs per replica. 

207 port: Container port. 

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

209 env_vars: Environment variables as KEY=VALUE strings. 

210 """ 

211 args = [ 

212 "inference", 

213 "deploy", 

214 name, 

215 "--mooncake-mode", 

216 mooncake_mode, 

217 "--prefill-replicas", 

218 str(prefill), 

219 "--decode-replicas", 

220 str(decode), 

221 "--gpu-count", 

222 str(gpu_count), 

223 "--port", 

224 str(port), 

225 ] 

226 if image: 

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

228 if region: 

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

230 for env in env_vars or []: 

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

232 return cli_runner._run_cli(*args) 

233 

234 

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

236@audit_logged 

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

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

239 

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

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

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

243 

244 Args: 

245 name: Endpoint name. 

246 prefill: New prefill (X) instance count. 

247 decode: New decode (Y) instance count. 

248 """ 

249 return cli_runner._run_cli( 

250 "inference", 

251 "set-topology", 

252 name, 

253 "--prefill", 

254 str(prefill), 

255 "--decode", 

256 str(decode), 

257 ) 

258 

259 

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

261@audit_logged 

262def configure_mooncake_store( 

263 endpoint_name: str, 

264 cold_tier: bool | None = None, 

265 offload: str | None = None, 

266 global_segment_size: int | None = None, 

267 local_buffer_size: int | None = None, 

268 enabled: bool | None = None, 

269) -> str: 

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

271 

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

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

274 the endpoint's existing store block before reconciliation. 

275 

276 Args: 

277 endpoint_name: Endpoint whose store configuration should change. 

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

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

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

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

282 enabled: Enable or disable the shared store itself. 

283 """ 

284 if all( 

285 value is None 

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

287 ): 

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

289 

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

291 if cold_tier is not None: 291 ↛ 293line 291 didn't jump to line 293 because the condition on line 291 was always true

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

293 if offload is not None: 

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

295 if global_segment_size is not None: 

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

297 if local_buffer_size is not None: 

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

299 if enabled is not None: 299 ↛ 301line 299 didn't jump to line 301 because the condition on line 299 was always true

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

301 return cli_runner._run_cli(*args) 

302 

303 

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

305@audit_logged 

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

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

308 

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

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

311 

312 Args: 

313 name: Endpoint name. 

314 """ 

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

316 

317 

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

319@audit_logged 

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

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

322 

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

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

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

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

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

328 

329 Args: 

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

331 local_path: Local file or directory to upload. 

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

333 """ 

334 return await asyncio.to_thread( 

335 cli_runner._run_cli, 

336 "inference", 

337 "populate-kv", 

338 endpoint_name, 

339 local_path, 

340 "-r", 

341 region, 

342 ) 

343 

344 

345if is_enabled(FLAG_DESTRUCTIVE_OPERATIONS): 

346 

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

348 @audit_logged 

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

350 """[gated by GCO_ENABLE_DESTRUCTIVE_OPERATIONS] destructive. 

351 

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

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

354 

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

356 invocation when GCO_ENABLE_DESTRUCTIVE_OPERATIONS is not enabled: the 

357 request is rejected and no endpoint resource is removed. 

358 

359 Args: 

360 name: Endpoint name. 

361 """ 

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

363 _endpoint_is_disaggregated, name 

364 ): 

365 message = ( 

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

367 "operations are disabled. Set GCO_ENABLE_DESTRUCTIVE_OPERATIONS=true " 

368 "to allow this deletion." 

369 ) 

370 await _ctx_warning(message) 

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

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

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

374 

375 

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

377@audit_logged 

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

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

380 

381 Args: 

382 name: Endpoint name. 

383 image: New image to canary. 

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

385 """ 

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

387 

388 

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

390@audit_logged 

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

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

393 

394 Args: 

395 name: Endpoint name. 

396 """ 

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

398 

399 

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

401@audit_logged 

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

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

404 

405 Args: 

406 name: Endpoint name. 

407 """ 

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

409 

410 

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

412@audit_logged 

413def invoke_inference( 

414 name: str, 

415 prompt: str, 

416 max_tokens: int = 100, 

417 api_path: str | None = None, 

418 region: str | None = None, 

419) -> str: 

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

421 

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

423 framework (vLLM, TGI, Triton), and routes the request through the API 

424 Gateway with SigV4 authentication. 

425 

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

427 with chat models, use chat_inference instead. 

428 

429 Args: 

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

431 prompt: Text prompt to send to the model. 

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

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

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

435 

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

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

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

439 token-by-token output is required. 

440 """ 

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

442 if api_path: 

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

444 if region: 

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

446 return cli_runner._run_cli(*args) 

447 

448 

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

450@audit_logged 

451def chat_inference( 

452 name: str, 

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

454 max_tokens: int = 256, 

455 temperature: float | None = None, 

456 region: str | None = None, 

457) -> str: 

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

459 

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

461 vLLM, TGI (with --api-protocol openai), and any OpenAI-compatible server. 

462 

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

464 'content' keys. 

465 

466 Args: 

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

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

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

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

471 region: Target region for the request. 

472 

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

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

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

476 token-by-token output is required. 

477 """ 

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

479 if temperature is not None: 

480 body["temperature"] = temperature 

481 data_str = json.dumps(body) 

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

483 if region: 

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

485 return cli_runner._run_cli(*args) 

486 

487 

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

489@audit_logged 

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

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

492 

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

494 Useful to verify readiness before sending inference requests. 

495 

496 Args: 

497 name: Endpoint name. 

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

499 """ 

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

501 if region: 

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

503 return cli_runner._run_cli(*args) 

504 

505 

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

507@audit_logged 

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

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

510 

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

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

513 Works with vLLM and other OpenAI-compatible servers. 

514 

515 Args: 

516 name: Endpoint name. 

517 region: Target region to query. 

518 """ 

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

520 if region: 

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

522 return cli_runner._run_cli(*args)