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.
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/PKG-INFO +1 -1
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/__init__.py +6 -0
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/caller_identity.py +9 -0
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/keystone_request_context.egg-info/PKG-INFO +1 -1
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/middleware.py +37 -27
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/pyproject.toml +1 -1
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/vars.py +28 -0
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/keystone_request_context.egg-info/SOURCES.txt +0 -0
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/keystone_request_context.egg-info/dependency_links.txt +0 -0
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/keystone_request_context.egg-info/requires.txt +0 -0
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/keystone_request_context.egg-info/top_level.txt +0 -0
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/py.typed +0 -0
- {keystone_request_context-0.3.0 → keystone_request_context-0.3.1}/setup.cfg +0 -0
|
@@ -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
|
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
137
|
-
#
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
tokens.append(ctx_var.set(value))
|
|
181
|
+
tokens.append(ctx_var.set(headers_snapshot.get(header_name)))
|
|
167
182
|
|
|
168
183
|
try:
|
|
169
|
-
|
|
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.
|
|
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",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|