readyagentsdev 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,585 @@
1
+ """Loopback Streamable HTTP composition: auth, DNS-rebinding, body limit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import inspect
8
+ import ipaddress
9
+ import json
10
+ import os
11
+ import secrets
12
+ import sys
13
+ import uuid
14
+ from collections import deque
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import urlparse
18
+
19
+ from readyagents.config import (
20
+ DEFAULT_MCP_TOKEN_ENV,
21
+ LOOPBACK_HOSTS,
22
+ MAX_CONCURRENT_RUNS_HARD,
23
+ MAX_HTTP_BODY_BYTES,
24
+ MAX_PENDING_RUNS_HARD,
25
+ get_settings,
26
+ )
27
+ from readyagents.errors import MCPError
28
+ from readyagents.mcp.server import construct_server, streamable_http_app
29
+
30
+ _SCOPE_REQUEST_ID = "readyagents.request_id"
31
+ _MAX_REQUEST_ID_LEN = 128
32
+
33
+
34
+ def assert_loopback_host(host: str) -> str:
35
+ """Reject non-loopback bind hosts. Allow 127.0.0.1, localhost, and ::1."""
36
+ if host is None or not str(host).strip():
37
+ raise MCPError("MCP HTTP bind host must be loopback, not empty.")
38
+ raw = str(host).strip()
39
+ if raw.startswith("[") and raw.endswith("]") and len(raw) > 2:
40
+ raw = raw[1:-1]
41
+ lowered = raw.lower()
42
+ if lowered in {"0.0.0.0", "::", "[::]"}:
43
+ raise MCPError(
44
+ f"MCP HTTP bind host '{host}' is not loopback. Use 127.0.0.1, localhost, or ::1."
45
+ )
46
+ if lowered in LOOPBACK_HOSTS:
47
+ return raw
48
+ try:
49
+ ip = ipaddress.ip_address(raw)
50
+ except ValueError as exc:
51
+ raise MCPError(
52
+ f"MCP HTTP bind host '{host}' is not loopback. Use 127.0.0.1, localhost, or ::1."
53
+ ) from exc
54
+ if ip.is_unspecified or not ip.is_loopback:
55
+ raise MCPError(
56
+ f"MCP HTTP bind host '{host}' is not loopback. Use 127.0.0.1, localhost, or ::1."
57
+ )
58
+ return raw
59
+
60
+
61
+ def resolve_bearer_token(*, auth_mode: str, token_env: str, bind_host: str) -> str | None:
62
+ """Return a bearer token, or None when ``--auth none`` on loopback."""
63
+ mode = (auth_mode or "").strip().lower()
64
+ env_name = (token_env or DEFAULT_MCP_TOKEN_ENV).strip() or DEFAULT_MCP_TOKEN_ENV
65
+ bind_host = assert_loopback_host(bind_host)
66
+ if mode == "none":
67
+ return None
68
+ if mode != "token":
69
+ raise MCPError("Invalid --auth value. Use token or none.")
70
+ existing = os.environ.get(env_name)
71
+ if existing is not None and existing.strip():
72
+ return existing.strip()
73
+ return secrets.token_urlsafe(32)
74
+
75
+
76
+ def compose_http_app(
77
+ *,
78
+ server: Any,
79
+ coordinator: Any = None,
80
+ token: str | None,
81
+ bind_host: str,
82
+ bind_port: int,
83
+ max_body_bytes: int = MAX_HTTP_BODY_BYTES,
84
+ ) -> Any:
85
+ """SDK Streamable HTTP app plus /runs routes, wrapped in pure ASGI middleware."""
86
+ bind_host = assert_loopback_host(bind_host)
87
+ if not (1 <= int(bind_port) <= 65535):
88
+ raise MCPError("MCP HTTP port must be between 1 and 65535.")
89
+ secret = token.strip() if isinstance(token, str) and token.strip() else None
90
+ mcp_app = streamable_http_app(
91
+ server, host=bind_host, port=int(bind_port), max_body_bytes=max_body_bytes
92
+ )
93
+ router = getattr(mcp_app, "router", None)
94
+ routes = getattr(router, "routes", None)
95
+ if routes is not None:
96
+ routes[0:0] = _run_routes(coordinator)
97
+ # Do not wrap in a second Starlette: StreamableHTTPSessionManager.run() is once-only.
98
+ return CacheControlMiddleware(
99
+ AuthMiddleware(
100
+ HostOriginMiddleware(
101
+ BodyLimitMiddleware(mcp_app, max_body_bytes=max_body_bytes),
102
+ bind_host=bind_host,
103
+ bind_port=int(bind_port),
104
+ ),
105
+ token=secret,
106
+ )
107
+ )
108
+
109
+
110
+ def serve_streamable_http(
111
+ *,
112
+ host: str,
113
+ port: int,
114
+ auth_mode: str,
115
+ token_env: str,
116
+ max_concurrent_runs: int,
117
+ max_pending_runs: int,
118
+ allow_http: bool | None = None,
119
+ workspace: Path | None = None,
120
+ ) -> None:
121
+ """Foreground loopback Streamable HTTP server. Blocking. Not started on import."""
122
+ coordinator: Any = None
123
+ try:
124
+ host = assert_loopback_host(host)
125
+ if not (1 <= int(port) <= 65535):
126
+ raise MCPError("MCP HTTP port must be between 1 and 65535.")
127
+ max_concurrent_runs = max(1, min(int(max_concurrent_runs), MAX_CONCURRENT_RUNS_HARD))
128
+ max_pending_runs = max(1, min(int(max_pending_runs), MAX_PENDING_RUNS_HARD))
129
+ mode = (auth_mode or "").strip().lower()
130
+ env_name = (token_env or DEFAULT_MCP_TOKEN_ENV).strip() or DEFAULT_MCP_TOKEN_ENV
131
+ prior = (os.environ.get(env_name) or "").strip()
132
+ token = resolve_bearer_token(auth_mode=mode, token_env=env_name, bind_host=host)
133
+ if mode == "token" and not prior:
134
+ print("Generated MCP bearer token (shown once):", file=sys.stderr)
135
+ print(token, file=sys.stderr)
136
+ elif mode == "none":
137
+ print(
138
+ "Warning: MCP HTTP authentication is disabled (--auth none). "
139
+ "Loopback-only; do not expose this process.",
140
+ file=sys.stderr,
141
+ )
142
+ settings = get_settings()
143
+ root = Path(workspace) if workspace is not None else settings.workspace_path()
144
+ server = construct_server(allow_http=allow_http, workspace=root)
145
+ coordinator = _try_run_coordinator(
146
+ workspace=root,
147
+ max_concurrent_runs=max_concurrent_runs,
148
+ max_pending_runs=max_pending_runs,
149
+ )
150
+ app = compose_http_app(
151
+ server=server,
152
+ coordinator=coordinator,
153
+ token=token,
154
+ bind_host=host,
155
+ bind_port=int(port),
156
+ max_body_bytes=MAX_HTTP_BODY_BYTES,
157
+ )
158
+ try:
159
+ import uvicorn
160
+ except ImportError as exc:
161
+ raise MCPError('MCP extra is not installed. Run: pip install -e ".[mcp]"') from exc
162
+ uvicorn.run(app, host=host, port=int(port), log_level="warning")
163
+ finally:
164
+ shutdown = getattr(coordinator, "shutdown", None) if coordinator is not None else None
165
+ if callable(shutdown):
166
+ shutdown()
167
+
168
+
169
+ def _run_routes(coordinator: Any) -> list[Any]:
170
+ if coordinator is None:
171
+ return []
172
+ try:
173
+ from readyagents.mcp.run_api import build_run_routes
174
+ except ImportError:
175
+ return []
176
+ return list(build_run_routes(coordinator))
177
+
178
+
179
+ def _try_run_coordinator(
180
+ *,
181
+ workspace: Path | None,
182
+ max_concurrent_runs: int,
183
+ max_pending_runs: int,
184
+ ) -> Any | None:
185
+ try:
186
+ from readyagents.mcp.run_api import RunCoordinator
187
+ except ImportError:
188
+ return None
189
+ desired = {
190
+ "settings": get_settings(),
191
+ "workspace": workspace,
192
+ "max_concurrent_runs": max_concurrent_runs,
193
+ "max_pending_runs": max_pending_runs,
194
+ }
195
+ try:
196
+ signature = inspect.signature(RunCoordinator)
197
+ except (TypeError, ValueError):
198
+ return RunCoordinator()
199
+ params = signature.parameters
200
+ if any(param.kind is inspect.Parameter.VAR_KEYWORD for param in params.values()):
201
+ return RunCoordinator(**desired)
202
+ accepted = {key: value for key, value in desired.items() if key in params}
203
+ return RunCoordinator(**accepted)
204
+
205
+
206
+ def _header(scope: dict[str, Any], name: str) -> str | None:
207
+ key = name.lower().encode("latin-1")
208
+ for raw_key, raw_value in scope.get("headers") or ():
209
+ if raw_key.lower() == key:
210
+ return raw_value.decode("latin-1")
211
+ return None
212
+
213
+
214
+ def _header_values(scope: dict[str, Any], name: str) -> list[str]:
215
+ key = name.lower().encode("latin-1")
216
+ values: list[str] = []
217
+ for raw_key, raw_value in scope.get("headers") or ():
218
+ if raw_key.lower() == key:
219
+ values.append(raw_value.decode("latin-1"))
220
+ return values
221
+
222
+
223
+ def _parse_request_id(scope: dict[str, Any]) -> str:
224
+ raw = _header(scope, "x-request-id")
225
+ if raw is None:
226
+ return uuid.uuid4().hex
227
+ value = raw.strip()
228
+ if (
229
+ 1 <= len(value) <= _MAX_REQUEST_ID_LEN
230
+ and value.isascii()
231
+ and value.isprintable()
232
+ and all(ch not in value for ch in " \t\r\n")
233
+ ):
234
+ return value
235
+ return uuid.uuid4().hex
236
+
237
+
238
+ def _ensure_request_id(scope: dict[str, Any]) -> str:
239
+ existing = scope.get(_SCOPE_REQUEST_ID)
240
+ if isinstance(existing, str) and existing:
241
+ return existing
242
+ request_id = _parse_request_id(scope)
243
+ scope[_SCOPE_REQUEST_ID] = request_id
244
+ return request_id
245
+
246
+
247
+ def _error_payload(*, error: str, message: str, request_id: str) -> dict[str, Any]:
248
+ return {
249
+ "ok": False,
250
+ "error": error,
251
+ "message": message,
252
+ "run_id": None,
253
+ "request_id": request_id,
254
+ }
255
+
256
+
257
+ async def _send_json(
258
+ send: Any,
259
+ *,
260
+ status: int,
261
+ payload: dict[str, Any],
262
+ extra_headers: list[tuple[bytes, bytes]] | None = None,
263
+ ) -> None:
264
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
265
+ request_id = str(payload.get("request_id") or "")
266
+ headers: list[tuple[bytes, bytes]] = [
267
+ (b"content-type", b"application/json; charset=utf-8"),
268
+ (b"content-length", str(len(body)).encode("ascii")),
269
+ (b"cache-control", b"no-store"),
270
+ ]
271
+ if request_id:
272
+ headers.append((b"x-request-id", request_id.encode("utf-8", errors="replace")))
273
+ if extra_headers:
274
+ headers.extend(extra_headers)
275
+ await send({"type": "http.response.start", "status": status, "headers": headers})
276
+ await send({"type": "http.response.body", "body": body})
277
+
278
+
279
+ def _canonical_host(host: str) -> str:
280
+ value = host.strip().lower()
281
+ if value.startswith("[") and value.endswith("]") and len(value) > 2:
282
+ value = value[1:-1]
283
+ if value == "0:0:0:0:0:0:0:1":
284
+ return "::1"
285
+ try:
286
+ return str(ipaddress.ip_address(value))
287
+ except ValueError:
288
+ return value
289
+
290
+
291
+ def _split_host_port(value: str) -> tuple[str, int | None]:
292
+ text = value.strip()
293
+ if not text:
294
+ raise ValueError("empty host")
295
+ if text.startswith("["):
296
+ end = text.find("]")
297
+ if end == -1:
298
+ raise ValueError("bad ipv6 host")
299
+ host = text[1:end]
300
+ rest = text[end + 1 :]
301
+ if not rest:
302
+ return host, None
303
+ if rest.startswith(":") and rest[1:].isdigit():
304
+ return host, int(rest[1:])
305
+ raise ValueError("bad ipv6 host port")
306
+ if text.count(":") == 1:
307
+ host, port_s = text.rsplit(":", 1)
308
+ if port_s.isdigit():
309
+ return host, int(port_s)
310
+ return text, None
311
+
312
+
313
+ def _hostname_allowed(hostname: str, bind_host: str) -> bool:
314
+ got = _canonical_host(hostname)
315
+ allowed = {_canonical_host(bind_host)}
316
+ for item in LOOPBACK_HOSTS:
317
+ allowed.add(_canonical_host(item))
318
+ return got in allowed
319
+
320
+
321
+ def _extract_bearer(authorization: str | None) -> str | None:
322
+ if not authorization:
323
+ return None
324
+ parts = authorization.split(None, 1)
325
+ if len(parts) != 2 or parts[0].lower() != "bearer":
326
+ return None
327
+ provided = parts[1].strip()
328
+ return provided or None
329
+
330
+
331
+ def _tokens_match(provided: str, expected: str) -> bool:
332
+ got = hashlib.sha256(provided.encode("utf-8")).digest()
333
+ want = hashlib.sha256(expected.encode("utf-8")).digest()
334
+ return hmac.compare_digest(got, want)
335
+
336
+
337
+ class BodyLimitMiddleware:
338
+ """Reject oversized HTTP bodies with 413 before the inner app."""
339
+
340
+ def __init__(self, app: Any, *, max_body_bytes: int) -> None:
341
+ self.app = app
342
+ self.max_body_bytes = max_body_bytes
343
+
344
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
345
+ if scope["type"] != "http":
346
+ await self.app(scope, receive, send)
347
+ return
348
+ request_id = _ensure_request_id(scope)
349
+ declared = _header(scope, "content-length")
350
+ if declared is not None:
351
+ try:
352
+ size = int(declared)
353
+ except ValueError:
354
+ await _send_json(
355
+ send,
356
+ status=400,
357
+ payload=_error_payload(
358
+ error="BadRequest",
359
+ message="Invalid Content-Length header.",
360
+ request_id=request_id,
361
+ ),
362
+ )
363
+ return
364
+ if size < 0:
365
+ await _send_json(
366
+ send,
367
+ status=400,
368
+ payload=_error_payload(
369
+ error="BadRequest",
370
+ message="Invalid Content-Length header.",
371
+ request_id=request_id,
372
+ ),
373
+ )
374
+ return
375
+ if size > self.max_body_bytes:
376
+ await _send_json(
377
+ send,
378
+ status=413,
379
+ payload=_error_payload(
380
+ error="PayloadTooLarge",
381
+ message="Request body exceeds the maximum allowed size.",
382
+ request_id=request_id,
383
+ ),
384
+ )
385
+ return
386
+ method = str(scope.get("method") or "").upper()
387
+ if method not in {"POST", "PUT", "PATCH"}:
388
+ await self.app(scope, receive, send)
389
+ return
390
+ received = bytearray()
391
+ saw_request = False
392
+ complete = False
393
+ trailing: dict[str, Any] | None = None
394
+ while True:
395
+ message = await receive()
396
+ if message["type"] != "http.request":
397
+ trailing = message
398
+ break
399
+ saw_request = True
400
+ chunk = message.get("body", b"") or b""
401
+ if len(received) + len(chunk) > self.max_body_bytes:
402
+ await _send_json(
403
+ send,
404
+ status=413,
405
+ payload=_error_payload(
406
+ error="PayloadTooLarge",
407
+ message="Request body exceeds the maximum allowed size.",
408
+ request_id=request_id,
409
+ ),
410
+ )
411
+ return
412
+ received.extend(chunk)
413
+ if not message.get("more_body", False):
414
+ complete = True
415
+ break
416
+ cached: deque[dict[str, Any]] = deque()
417
+ if saw_request:
418
+ cached.append(
419
+ {
420
+ "type": "http.request",
421
+ "body": bytes(received),
422
+ "more_body": not complete,
423
+ }
424
+ )
425
+ if trailing is not None:
426
+ cached.append(trailing)
427
+
428
+ async def replay() -> dict[str, Any]:
429
+ if cached:
430
+ return cached.popleft()
431
+ return await receive()
432
+
433
+ await self.app(scope, replay, send)
434
+
435
+
436
+ class HostOriginMiddleware:
437
+ """Reject bad Host/Origin before the inner app (DNS-rebinding defense)."""
438
+
439
+ def __init__(self, app: Any, *, bind_host: str, bind_port: int) -> None:
440
+ self.app = app
441
+ self.bind_host = bind_host
442
+ self.bind_port = bind_port
443
+
444
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
445
+ if scope["type"] != "http":
446
+ await self.app(scope, receive, send)
447
+ return
448
+ request_id = _ensure_request_id(scope)
449
+ hosts = _header_values(scope, "host")
450
+ if not hosts:
451
+ await _send_json(
452
+ send,
453
+ status=400,
454
+ payload=_error_payload(
455
+ error="InvalidHost",
456
+ message="Missing Host header.",
457
+ request_id=request_id,
458
+ ),
459
+ )
460
+ return
461
+ if len(hosts) > 1 or not self._host_ok(hosts[0]):
462
+ await _send_json(
463
+ send,
464
+ status=421,
465
+ payload=_error_payload(
466
+ error="InvalidHost",
467
+ message="Invalid Host header.",
468
+ request_id=request_id,
469
+ ),
470
+ )
471
+ return
472
+ origins = _header_values(scope, "origin")
473
+ if len(origins) > 1 or (origins and not self._origin_ok(origins[0])):
474
+ await _send_json(
475
+ send,
476
+ status=403,
477
+ payload=_error_payload(
478
+ error="Forbidden",
479
+ message="Invalid Origin header.",
480
+ request_id=request_id,
481
+ ),
482
+ )
483
+ return
484
+ await self.app(scope, receive, send)
485
+
486
+ def _host_ok(self, host_header: str) -> bool:
487
+ try:
488
+ hostname, port = _split_host_port(host_header)
489
+ except ValueError:
490
+ return False
491
+ if not hostname or not _hostname_allowed(hostname, self.bind_host):
492
+ return False
493
+ if port is not None and port != self.bind_port:
494
+ return False
495
+ return True
496
+
497
+ def _origin_ok(self, origin: str) -> bool:
498
+ parsed = urlparse(origin)
499
+ if parsed.scheme != "http":
500
+ return False
501
+ if parsed.path not in {"", "/"}:
502
+ return False
503
+ if parsed.params or parsed.query or parsed.fragment:
504
+ return False
505
+ if parsed.username or parsed.password:
506
+ return False
507
+ hostname = parsed.hostname
508
+ if not hostname or not _hostname_allowed(hostname, self.bind_host):
509
+ return False
510
+ port = parsed.port if parsed.port is not None else 80
511
+ return port == self.bind_port
512
+
513
+
514
+ class AuthMiddleware:
515
+ """Require Authorization: Bearer when a token is configured."""
516
+
517
+ def __init__(self, app: Any, *, token: str | None) -> None:
518
+ self.app = app
519
+ self.token = token
520
+
521
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
522
+ if scope["type"] != "http" or self.token is None:
523
+ await self.app(scope, receive, send)
524
+ return
525
+ request_id = _ensure_request_id(scope)
526
+ provided = _extract_bearer(_header(scope, "authorization"))
527
+ if provided is None or not _tokens_match(provided, self.token):
528
+ await _send_json(
529
+ send,
530
+ status=401,
531
+ payload=_error_payload(
532
+ error="Unauthorized",
533
+ message="Authorization required.",
534
+ request_id=request_id,
535
+ ),
536
+ extra_headers=[(b"www-authenticate", b"Bearer")],
537
+ )
538
+ return
539
+ await self.app(scope, receive, send)
540
+
541
+
542
+ class CacheControlMiddleware:
543
+ """Echo X-Request-Id and set Cache-Control: no-store on HTTP responses."""
544
+
545
+ def __init__(self, app: Any) -> None:
546
+ self.app = app
547
+
548
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
549
+ if scope["type"] != "http":
550
+ await self.app(scope, receive, send)
551
+ return
552
+ request_id = _ensure_request_id(scope)
553
+
554
+ async def send_wrapper(message: dict[str, Any]) -> None:
555
+ if message.get("type") == "http.response.start":
556
+ headers = list(message.get("headers") or [])
557
+ headers = _apply_response_headers(headers, request_id)
558
+ message = {**message, "headers": headers}
559
+ await send(message)
560
+
561
+ await self.app(scope, receive, send_wrapper)
562
+
563
+
564
+ def _apply_response_headers(
565
+ headers: list[tuple[bytes, bytes]], request_id: str
566
+ ) -> list[tuple[bytes, bytes]]:
567
+ out: list[tuple[bytes, bytes]] = []
568
+ saw_cache = False
569
+ rid = request_id.encode("utf-8", errors="replace")
570
+ for key, value in headers:
571
+ lowered = key.lower()
572
+ if lowered == b"cache-control":
573
+ text = value.decode("latin-1")
574
+ if "no-store" not in text.lower():
575
+ text = f"{text}, no-store" if text.strip() else "no-store"
576
+ out.append((b"cache-control", text.encode("latin-1")))
577
+ saw_cache = True
578
+ elif lowered == b"x-request-id":
579
+ continue
580
+ else:
581
+ out.append((key, value))
582
+ if not saw_cache:
583
+ out.append((b"cache-control", b"no-store"))
584
+ out.append((b"x-request-id", rid))
585
+ return out