keystone-request-context 0.3.0__tar.gz → 0.3.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keystone-request-context
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Shared request-scoped ContextVar slots + Starlette middleware for Keystone AI services.
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: starlette<2,>=1.3.1
@@ -48,11 +48,14 @@ from .middleware import (
48
48
  normalize_correlation_id,
49
49
  )
50
50
  from .vars import (
51
+ actor_id,
51
52
  agent_id_var,
52
53
  correlation_id_var,
54
+ end_user_id_var,
53
55
  entity_id_var,
54
56
  headers_var,
55
57
  request_id_var,
58
+ service_account_id_var,
56
59
  stage_id_var,
57
60
  trace_id_var,
58
61
  traceparent_var,
@@ -65,8 +68,10 @@ __all__ = [
65
68
  "REQUEST_ID_HEADER",
66
69
  "LLMCallerIdentity",
67
70
  "RequestContextMiddleware",
71
+ "actor_id",
68
72
  "agent_id_var",
69
73
  "correlation_id_var",
74
+ "end_user_id_var",
70
75
  "entity_id_var",
71
76
  "gateway_identity_headers",
72
77
  "get_caller_identity",
@@ -80,6 +85,7 @@ __all__ = [
80
85
  "stage_id_var",
81
86
  "trace_id_var",
82
87
  "traceparent_var",
88
+ "service_account_id_var",
83
89
  "user_id_var",
84
90
  "workspace_id_var",
85
91
  ]
@@ -62,6 +62,10 @@ class LLMCallerIdentity:
62
62
  # handed to the pod per-request. A CREDENTIAL: repr=False keeps it out of any accidental
63
63
  # log/str of this bundle; it is emitted outbound only as X-Invocation-Key.
64
64
  invocation_key: str = field(default="", repr=False)
65
+ # FDP-5466 — the end user this turn serves (inbound ``X-EndUser-ID``). Empty on builder
66
+ # (Portal) traffic. Forwarded verbatim as ``X-EndUser-ID``; llm-gateway meters the call to
67
+ # this end user's wallet when present. Last field so positional construction is unchanged.
68
+ end_user_id: str = ""
65
69
 
66
70
 
67
71
  _caller_identity: ContextVar[LLMCallerIdentity | None] = ContextVar(
@@ -155,6 +159,7 @@ def gateway_identity_headers(*, auth_token: str, operation: str, caller_service:
155
159
  user_id = caller.user_id
156
160
  correlation_id = caller.correlation_id
157
161
  invocation_key = "" if caller is None else caller.invocation_key
162
+ end_user_id = "" if caller is None else caller.end_user_id
158
163
  headers = {
159
164
  "Content-Type": "application/json",
160
165
  "X-Entity-ID": entity_id,
@@ -178,6 +183,10 @@ def gateway_identity_headers(*, auth_token: str, operation: str, caller_service:
178
183
  # header" routing problem Ops raised: the Kong plugin just reads X-Invocation-Key.
179
184
  if invocation_key:
180
185
  headers["X-Invocation-Key"] = invocation_key
186
+ # FDP-5466 — forward the end user as-is. Additive: no end user → no header, every other
187
+ # header unchanged. X-User-ID is NOT dropped or rewritten when both are present.
188
+ if end_user_id:
189
+ headers["X-EndUser-ID"] = end_user_id
181
190
  self_agent = get_self_agent()
182
191
  if self_agent is not None:
183
192
  # Agent pod (FDP-3663): name the agent MAKING this hop — its key, budget, model allowlist
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keystone-request-context
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Shared request-scoped ContextVar slots + Starlette middleware for Keystone AI services.
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: starlette<2,>=1.3.1
@@ -33,14 +33,16 @@ from contextvars import ContextVar
33
33
  from typing import TYPE_CHECKING
34
34
 
35
35
  import uuid_utils
36
- from starlette.middleware.base import BaseHTTPMiddleware
36
+ from starlette.datastructures import Headers, MutableHeaders
37
37
 
38
38
  from .vars import (
39
39
  agent_id_var,
40
40
  correlation_id_var,
41
+ end_user_id_var,
41
42
  entity_id_var,
42
43
  headers_var,
43
44
  request_id_var,
45
+ service_account_id_var,
44
46
  stage_id_var,
45
47
  trace_id_var,
46
48
  traceparent_var,
@@ -49,11 +51,7 @@ from .vars import (
49
51
  )
50
52
 
51
53
  if TYPE_CHECKING:
52
- from collections.abc import Awaitable, Callable
53
-
54
- from starlette.requests import Request
55
- from starlette.responses import Response
56
- from starlette.types import ASGIApp
54
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
57
55
 
58
56
  REQUEST_ID_HEADER = "X-Request-ID"
59
57
  CORRELATION_ID_HEADER = "X-Correlation-ID"
@@ -70,6 +68,11 @@ _HEADER_TO_VAR: tuple[tuple[str, ContextVar[str | None]], ...] = (
70
68
  ("x-entity-id", entity_id_var),
71
69
  ("x-stage-id", stage_id_var),
72
70
  ("x-agent-id", agent_id_var),
71
+ # FDP-4692 — without this slot an API-key (service-account) caller is
72
+ # invisible to every downstream client that builds headers from the context.
73
+ ("x-service-account-id", service_account_id_var),
74
+ # FDP-5466 — the end user a Front Desk / Headless / channel turn serves.
75
+ ("x-enduser-id", end_user_id_var),
73
76
  )
74
77
 
75
78
 
@@ -117,26 +120,32 @@ def normalize_correlation_id(value: str | None) -> str | None:
117
120
  _normalize_correlation_id = normalize_correlation_id
118
121
 
119
122
 
120
- class RequestContextMiddleware(BaseHTTPMiddleware):
123
+ class RequestContextMiddleware:
121
124
  """Populate the request-context ``ContextVar`` slots for the request lifetime.
122
125
 
123
126
  See module docstring for the full contract.
127
+
128
+ Pure ASGI (FDP-6889), not ``BaseHTTPMiddleware``. ``BaseHTTPMiddleware`` ran the rest of the
129
+ stack in a child task and pushed every response chunk through a memory stream. Here the app
130
+ runs in the caller's task behind a thin ``send`` wrapper: the slots stay set for the whole
131
+ response (streamed body included) and are reset once it is done, and the two id headers are
132
+ written on ``http.response.start``. A middleware mounted OUTSIDE this one still writes after
133
+ it and wins, as before. Non-HTTP scopes (websocket, lifespan) pass through untouched.
124
134
  """
125
135
 
126
136
  def __init__(self, app: ASGIApp) -> None:
127
- super().__init__(app)
137
+ self.app = app
138
+
139
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
140
+ if scope["type"] != "http":
141
+ await self.app(scope, receive, send)
142
+ return
128
143
 
129
- async def dispatch(
130
- self,
131
- request: Request,
132
- call_next: Callable[[Request], Awaitable[Response]],
133
- ) -> Response:
134
144
  # 1. Materialize the lowercased-key header snapshot.
135
145
  #
136
- # Starlette's ``request.headers`` already supports case-insensitive
137
- # lookup, but downstream consumers (event_bus DSL, @require) expect
138
- # a plain dict so they aren't coupled to Starlette types.
139
- headers_snapshot: dict[str, str] = {key.lower(): value for key, value in request.headers.items()}
146
+ # Downstream consumers (event_bus DSL, @require) expect a plain dict so they aren't
147
+ # coupled to Starlette types. Later duplicates win, as with ``request.headers.items()``.
148
+ headers_snapshot: dict[str, str] = {key.lower(): value for key, value in Headers(scope=scope).items()}
140
149
 
141
150
  # 2. Resolve / mint X-Request-ID (per-API-call) AND X-Correlation-ID
142
151
  # (per-turn). Auto-gen UUID v7 for either when absent so logs always
@@ -147,8 +156,7 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
147
156
  # (dashes stripped, lowercased) so logs / forward / Langfuse trace_id
148
157
  # all share one literal string. Malformed inbound value → re-mint a
149
158
  # UUID v7 and normalize that.
150
- correlation_raw = headers_snapshot.get("x-correlation-id")
151
- correlation_id = _normalize_correlation_id(correlation_raw)
159
+ correlation_id = _normalize_correlation_id(headers_snapshot.get("x-correlation-id"))
152
160
  if correlation_id is None:
153
161
  # Either no header sent, or value wasn't UUID-shaped — mint fresh.
154
162
  correlation_id = _normalize_correlation_id(_generate_request_id())
@@ -157,16 +165,23 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
157
165
  headers_snapshot["x-request-id"] = request_id
158
166
  headers_snapshot["x-correlation-id"] = correlation_id
159
167
 
168
+ # 4. Echo resolved IDs so clients can correlate logs across hops.
169
+ async def send_with_ids(message: Message) -> None:
170
+ if message["type"] == "http.response.start":
171
+ response_headers = MutableHeaders(scope=message)
172
+ response_headers[REQUEST_ID_HEADER] = request_id
173
+ response_headers[CORRELATION_ID_HEADER] = correlation_id
174
+ await send(message)
175
+
160
176
  # 3. Set every ContextVar, retaining tokens so we can reset them
161
177
  # in ``finally``. Resolved (minted-or-received) values already in
162
178
  # ``headers_snapshot`` win for both request_id and correlation_id.
163
179
  tokens = [headers_var.set(headers_snapshot)]
164
180
  for header_name, ctx_var in _HEADER_TO_VAR:
165
- value = headers_snapshot.get(header_name)
166
- tokens.append(ctx_var.set(value))
181
+ tokens.append(ctx_var.set(headers_snapshot.get(header_name)))
167
182
 
168
183
  try:
169
- response = await call_next(request)
184
+ await self.app(scope, receive, send_with_ids)
170
185
  finally:
171
186
  # Reset in reverse order — ContextVar.reset is strict about token
172
187
  # ordering when nested calls have stacked sets on the same var.
@@ -180,10 +195,5 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
180
195
  # exception into the response path.
181
196
  pass
182
197
 
183
- # 4. Echo resolved IDs so clients can correlate logs across hops.
184
- response.headers[REQUEST_ID_HEADER] = request_id
185
- response.headers[CORRELATION_ID_HEADER] = correlation_id
186
- return response
187
-
188
198
 
189
199
  __all__ = ["CORRELATION_ID_HEADER", "REQUEST_ID_HEADER", "RequestContextMiddleware"]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keystone-request-context"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Shared request-scoped ContextVar slots + Starlette middleware for Keystone AI services."
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
@@ -75,6 +75,14 @@ traceparent_var: ContextVar[str | None] = ContextVar("keystone_traceparent", def
75
75
  user_id_var: ContextVar[str | None] = ContextVar("keystone_user_id", default=None)
76
76
  """Authenticated principal (header ``X-User-ID``)."""
77
77
 
78
+ end_user_id_var: ContextVar[str | None] = ContextVar("keystone_end_user_id", default=None)
79
+ """End user served by this turn (header ``X-EndUser-ID``), FDP-5466.
80
+
81
+ Set only on end-user traffic (Front Desk, Headless, chat channels): Kong or
82
+ agent-conversations stamps it. Forward it verbatim on every outbound hop that
83
+ reaches llm-gateway, which meters the call to this end user's wallet.
84
+ Builder traffic (Portal) never carries it."""
85
+
78
86
  workspace_id_var: ContextVar[str | None] = ContextVar("keystone_workspace_id", default=None)
79
87
  """Active workspace scope (header ``X-Workspace-ID``)."""
80
88
 
@@ -88,6 +96,25 @@ agent_id_var: ContextVar[str | None] = ContextVar("keystone_agent_id", default=N
88
96
  """Caller agent identifier when traffic originates from another agent
89
97
  (header ``X-Agent-ID``)."""
90
98
 
99
+ service_account_id_var: ContextVar[str | None] = ContextVar("keystone_service_account_id", default=None)
100
+ """Caller service account (header ``X-Service-Account-ID``).
101
+
102
+ FDP-4692. An API-key caller arrives with this header and NO ``X-User-ID``, so a
103
+ service that reads only :data:`user_id_var` sees an anonymous request: it stamps
104
+ no author on the rows it writes and forwards no subject on its s2s hops, which
105
+ made every downstream PEP evaluate the call as a user."""
106
+
107
+
108
+ def actor_id() -> str | None:
109
+ """The id to stamp into ``created_by`` / ``updated_by`` for this request.
110
+
111
+ Precedence agent → service_account → user, matching the Java PEP's subject
112
+ derivation (``AuthzAspect``) so attribution and authorization never
113
+ disagree about who the caller is.
114
+ """
115
+ return agent_id_var.get() or service_account_id_var.get() or user_id_var.get()
116
+
117
+
91
118
  # ---------------------------------------------------------------------------
92
119
  # Escape hatch — full headers snapshot
93
120
  # ---------------------------------------------------------------------------
@@ -108,6 +135,7 @@ with a fresh dict to avoid bleeding state across requests.
108
135
  __all__ = [
109
136
  "agent_id_var",
110
137
  "correlation_id_var",
138
+ "end_user_id_var",
111
139
  "entity_id_var",
112
140
  "headers_var",
113
141
  "request_id_var",