idside 0.1.0.dev0__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.
idside/consent.py ADDED
@@ -0,0 +1,1580 @@
1
+ # Patents: https://sdk.idside.eu/patents
2
+ # This software may be covered by the patents and applications listed there.
3
+
4
+ """Public entry point: ``consent.wrap()``.
5
+
6
+ ⚠️ *Opaque tunnel* invariant: this module exposes **no** function to set or
7
+ modify preferences (no ``set_preferences``, ``override_consent``…). The SDK only
8
+ **reads** the state resolved by the cloud.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import inspect
15
+ import weakref
16
+ from collections.abc import Callable, Mapping
17
+ from contextlib import contextmanager
18
+ from contextvars import ContextVar
19
+ from typing import Any, NamedTuple, cast
20
+
21
+ from idside import _destination, _guard, _log
22
+ from idside._destination import Origin
23
+ from idside._routing import NO_ROUTE, CredentialRouter, Route, missing_message
24
+ from idside._transport import (
25
+ AsyncConsentClient,
26
+ AsyncHttpConsentClient,
27
+ ConsentClient,
28
+ ConsentDecision,
29
+ HttpConsentClient,
30
+ _named,
31
+ )
32
+ from idside.adapters import get_adapter_for
33
+ from idside.adapters._call_options import (
34
+ refuse_declared_rewrites,
35
+ refuse_underivable,
36
+ refuse_unenforceable,
37
+ )
38
+ from idside.adapters.base import ProviderAdapter
39
+ from idside.exceptions import (
40
+ ConflictingIdentityError,
41
+ CredentialRoutingError,
42
+ MissingCredentialError,
43
+ MissingIdentityError,
44
+ UnsupportedProviderError,
45
+ )
46
+ from idside.providers import ProviderDescriptor
47
+
48
+ __all__ = ["wrap"]
49
+
50
+ #: Either transport to the cloud. Which one a wrapped client got is decided once,
51
+ #: at ``wrap()`` time, from the shape of the client itself.
52
+ AnyConsentClient = ConsentClient | AsyncConsentClient
53
+
54
+ #: Marker set on patched methods so a second ``wrap()`` never stacks enforcement.
55
+ _ENFORCED_MARKER = "__idside_consent_enforced__"
56
+
57
+
58
+ class _WrapState:
59
+ """Completion marker shared by the call sites patched in one wrap attempt."""
60
+
61
+ __slots__ = ("complete",)
62
+
63
+ def __init__(self) -> None:
64
+ self.complete = False
65
+
66
+
67
+ #: Methods that hand back a *fresh* client built from this one. Left alone, they
68
+ #: are a hole the size of the whole SDK: ``client.with_options(timeout=30)``
69
+ #: returns an unpatched client that still talks to the provider. They are
70
+ #: patched so the derived client comes back enforced too.
71
+ _DERIVING_METHODS = ("with_options", "copy", "with_middleware")
72
+
73
+ #: Aliases whose call builds a context manager and defers the HTTP request to
74
+ #: the moment it is entered. Every Python client's ``stream()`` helper is one:
75
+ #: ``chat.completions.stream(...)`` returns a ``ChatCompletionStreamManager``
76
+ #: and posts nothing until ``with`` runs. Authorizing at the call would mint an
77
+ #: ACT token and a ledger entry for a request that may never be made — a proof
78
+ #: of something that did not happen, which is worse than no proof at all. So the
79
+ #: cloud is asked in ``__enter__``, next to the request it authorizes.
80
+ #:
81
+ #: Applied to ALIASES only, never to a call site's own path: Mistral's
82
+ #: ``chat.stream`` is a call site named ``stream`` whose request goes out at the
83
+ #: call, and deferring that one would be the mirror-image mistake. This mirrors
84
+ #: the Node SDK's ``UNPATCHABLE_ALIASES``, which names the same helper for the
85
+ #: same reason — its return type is what makes the two SDKs treat it specially.
86
+ _DEFERRED_ALIASES = frozenset({"stream"})
87
+
88
+
89
+ class _InternalCall:
90
+ """Private proof tying one authorized call to its wrapper and route."""
91
+
92
+ __slots__ = ("call_site", "consumed", "scope", "token", "transport_route")
93
+
94
+ def __init__(self, call_site: str, token: str, scope: object) -> None:
95
+ self.call_site = call_site
96
+ self.token = token
97
+ self.scope = scope
98
+ self.consumed = False
99
+ self.transport_route: tuple[str, str] | None = None
100
+
101
+ def consume(self, scope: object, call_site: str, token: Any) -> bool:
102
+ """Consume this permit only for the matching call site and ACT token."""
103
+ if (
104
+ self.consumed
105
+ or scope is not self.scope
106
+ or call_site != self.call_site
107
+ or token != self.token
108
+ ):
109
+ return False
110
+ self.consumed = True
111
+ return True
112
+
113
+ def allows_transport(
114
+ self, scope: object, token: Any, method: str, path: str
115
+ ) -> bool:
116
+ """Bind transport use to this wrapper, token, and first request route."""
117
+ if scope is not self.scope or not isinstance(token, str) or token != self.token:
118
+ return False
119
+ route = (method.upper(), path)
120
+ if self.transport_route is None:
121
+ self.transport_route = route
122
+ return self.transport_route == route
123
+
124
+
125
+ # Context-local so concurrent async calls cannot consume one another's permit.
126
+ # It exists only while the already-authorized provider method is running; unlike
127
+ # the public ACT header, callers cannot manufacture this provenance.
128
+ _INTERNAL_CALL: ContextVar[_InternalCall | None] = ContextVar(
129
+ "idside_internal_call", default=None
130
+ )
131
+
132
+
133
+ class _TransportScope:
134
+ """The identity a wrapped client's permits and transport predicate share.
135
+
136
+ A class rather than a bare ``object()`` only because the predicate refers to
137
+ it weakly: see :func:`_transport_provenance`.
138
+ """
139
+
140
+ __slots__ = ("__weakref__",)
141
+
142
+
143
+ def _transport_provenance(
144
+ scope: _TransportScope, origin: Origin | None
145
+ ) -> _guard.Provenance:
146
+ """Build the guard predicate for one wrapped client.
147
+
148
+ ``origin`` is the one frozen at ``wrap()``, and it lives here — in the
149
+ predicate belonging to this client — rather than on the transport: a derived
150
+ client shares its parent's transport, and ``transport.base_url`` is not what
151
+ the provider libraries build URLs from anyway. The origin is compared before
152
+ the permit is consulted, so a request bound elsewhere cannot fix the route
153
+ the permit then holds its retries to. ``None`` is a client that names no
154
+ origin (a test double, an exotic adapter): nothing to compare against.
155
+
156
+ The scope is held WEAKLY. A derived client shares its parent's transport and
157
+ adds a predicate to it; held strongly, every ``client.with_options(...)``
158
+ made per request would stay in that list for the life of the process,
159
+ growing the memory and the work of every check. The scope lives exactly as
160
+ long as the client's enforced methods, so once the client is collected the
161
+ predicate reports itself dead and the guard drops it.
162
+ """
163
+ scope_ref = weakref.ref(scope)
164
+
165
+ def _has_provenance(
166
+ token: str, method: str, path: str, request_origin: Origin | None
167
+ ) -> bool:
168
+ if origin is not None and request_origin != origin:
169
+ return False
170
+ permit = _INTERNAL_CALL.get()
171
+ return permit is not None and permit.allows_transport(
172
+ scope_ref(), token, method, path
173
+ )
174
+
175
+ setattr(_has_provenance, _guard.ALIVE, lambda: scope_ref() is not None)
176
+ return _has_provenance
177
+
178
+
179
+ class _Lineage(NamedTuple):
180
+ """What a derived client must still share with the client it came from.
181
+
182
+ ``with_options()`` and ``copy()`` build a fresh client from keyword
183
+ arguments, and any of them can change what was wrapped:
184
+ ``base_url=`` moves every call elsewhere, ``default_headers=`` can add a
185
+ second ``openai-project`` that case-sensitive merging leaves beside the
186
+ routed one. So the parent's facts travel to the derivation and the derived
187
+ client is held to them — ``provider`` as detected (``None`` when the parent
188
+ itself could not be identified), ``origin`` as frozen, and the reserved
189
+ headers the parent was built with.
190
+
191
+ ``route_scopes`` are the nets the parent put on the clients named in
192
+ ``credentials``, by posture label. The derived client routes to the same
193
+ clients, so it sends through the same nets, held to the origins frozen when
194
+ the parent was wrapped — never to whatever those clients say at derivation.
195
+
196
+ Carried by a private argument, never an option of ``wrap()``: a public way
197
+ to hand ``wrap()`` a different origin would be a bypass (invariant #8).
198
+ """
199
+
200
+ provider: str | None
201
+ origin: Origin | None
202
+ reserved_headers: tuple[tuple[str, Any], ...]
203
+ route_scopes: Mapping[str, _TransportScope]
204
+
205
+
206
+ class _RoutedClient(NamedTuple):
207
+ """A client named in ``credentials``, and what ``wrap()`` froze of it.
208
+
209
+ ``labels`` are every posture it carries: one client may be named twice.
210
+ """
211
+
212
+ labels: tuple[str, ...]
213
+ client: Any
214
+ origin: Origin | None
215
+
216
+
217
+ def _lazily_permitted(permit: _InternalCall, result: Any) -> Any:
218
+ """Keep ``permit`` in force for I/O the provider has not done yet.
219
+
220
+ A declared call site can be a *generator function*: ``google.genai``'s
221
+ ``models.generate_content_stream`` is one. Calling it runs none of its body
222
+ and sends nothing — the request happens on the first ``next()``, long after
223
+ the ``with`` block that authorized the call has reset the context variable.
224
+ Without this, the net would refuse the very request this SDK just
225
+ authorized, and every Gemini streaming call would raise
226
+ ``UnenforcedCallError``.
227
+
228
+ The permit is re-established around each step of the iteration and around
229
+ nothing else: the consumer never gets control while one is in scope, so the
230
+ window stays exactly as wide as the provider's own work.
231
+ """
232
+ if inspect.isasyncgen(result):
233
+ return _permitted_async_iteration(permit, result)
234
+ if inspect.isgenerator(result):
235
+ return _permitted_iteration(permit, result)
236
+ return result
237
+
238
+
239
+ def _permitted_iteration(permit: _InternalCall, source: Any) -> Any:
240
+ """Drive ``source`` one step at a time with ``permit`` installed."""
241
+ try:
242
+ while True:
243
+ context_token = _INTERNAL_CALL.set(permit)
244
+ try:
245
+ item = next(source)
246
+ except StopIteration:
247
+ return
248
+ finally:
249
+ _INTERNAL_CALL.reset(context_token)
250
+ yield item
251
+ finally:
252
+ # The consumer may abandon the stream; the provider still owns an open
253
+ # HTTP response and has to be told, exactly as `yield from` would.
254
+ source.close()
255
+
256
+
257
+ async def _permitted_async_iteration(permit: _InternalCall, source: Any) -> Any:
258
+ """The async half of :func:`_permitted_iteration`, step for step."""
259
+ try:
260
+ while True:
261
+ context_token = _INTERNAL_CALL.set(permit)
262
+ try:
263
+ item = await source.__anext__()
264
+ except StopAsyncIteration:
265
+ return
266
+ finally:
267
+ _INTERNAL_CALL.reset(context_token)
268
+ yield item
269
+ finally:
270
+ await source.aclose()
271
+
272
+
273
+ def _is_async_call_site(func: Any) -> bool:
274
+ """Return True if calling ``func`` yields a coroutine (an async def site).
275
+
276
+ ``inspect.iscoroutinefunction`` already sees through bound methods; we also
277
+ peel ``functools.partial`` and ``functools.wraps`` layers so an async call
278
+ method hidden behind them (e.g. a decorated ``AsyncOpenAI`` entrypoint) is
279
+ still detected.
280
+ """
281
+ while isinstance(func, functools.partial):
282
+ func = func.func
283
+ if inspect.iscoroutinefunction(func):
284
+ return True
285
+ try:
286
+ unwrapped = inspect.unwrap(func)
287
+ except ValueError: # __wrapped__ cycle
288
+ return False
289
+ return unwrapped is not func and inspect.iscoroutinefunction(unwrapped)
290
+
291
+
292
+ def _is_enforced(method: Any) -> bool:
293
+ """True if this SDK has already patched ``method``."""
294
+ return bool(getattr(method, _ENFORCED_MARKER, False))
295
+
296
+
297
+ def _is_completely_wrapped(method: Any) -> bool:
298
+ """True only after the wrap attempt that patched ``method`` completed."""
299
+ state = getattr(method, _ENFORCED_MARKER, None)
300
+ return isinstance(state, _WrapState) and state.complete
301
+
302
+
303
+ async def _awaited(value: Any) -> Any:
304
+ """Await ``value`` if it is awaitable, otherwise take it as it is.
305
+
306
+ An async client is given the async transport to the cloud, so this is not
307
+ where an event loop gets blocked. It exists because ``consent_client=`` is a
308
+ documented override: a test double, or an application's own implementation
309
+ of the protocol, is a plain callable more often than not, and refusing one
310
+ on the async path would buy no safety at all.
311
+ """
312
+ return await value if inspect.isawaitable(value) else value
313
+
314
+
315
+ def wrap(
316
+ client: Any,
317
+ *,
318
+ consent_client: AnyConsentClient | None = None,
319
+ adapter: ProviderAdapter | None = None,
320
+ base_url: str | None = None,
321
+ api_key: str | None = None,
322
+ credentials: Mapping[str, Any] | None = None,
323
+ ) -> Any:
324
+ """Wrap an AI provider client to enforce ID Side consent.
325
+
326
+ On every intercepted AI call, the wrapped client: (1) resolves the user's
327
+ preferences from the ID Side cloud and obtains a signed ACT token, (2) forces
328
+ the provider's non-retention parameters (Type 1), (3) attaches the ACT token
329
+ (Type 2) as the ``X-AI-Consent-Token`` header, (4) calls the provider. If
330
+ consent cannot be verified, the call does not happen (fail-closed).
331
+
332
+ EVERY call site declared by the provider descriptor and present on the
333
+ client is enforced (e.g. both OpenAI entrypoints, ``chat.completions.create``
334
+ and ``responses.create``); declared sites absent from the client are skipped.
335
+ Wrapping is idempotent: calling ``wrap()`` again on the same client neither
336
+ stacks enforcement nor breaks the call.
337
+
338
+ One form of one call is deliberately let through: a method that RESUMES a
339
+ result instead of creating one — ``responses.stream(response_id=…)``, which
340
+ replays a stored response over a ``GET`` — creates nothing to consent to, so
341
+ it needs no identity and gets no token and no ledger entry. It is not a way
342
+ out: the call carries no ACT token, so the transport guard refuses it if it
343
+ turns out to carry a body.
344
+
345
+ The caller names the end user on every AI call with exactly ONE of two
346
+ kwargs; both are consumed by the SDK and never forwarded to the provider:
347
+
348
+ - ``ubt="eyJ..."`` — use this when your frontend runs the ID Side
349
+ Preference Center widget. The widget hands your frontend a **User
350
+ Binding Token** (a JWT signed by ID Side binding user, app, session and
351
+ consent state); forward it to your backend and pass it here. The SDK
352
+ treats it as opaque; the cloud verifies it, defeating identity spoofing
353
+ (the proof records ``binding="ubt"`` — the strong link).
354
+ - ``user_id="usr_..."`` — use this for backend-only integrations without
355
+ the widget. You assert the identity yourself; the proof records the
356
+ weaker ``binding="user_id"``.
357
+
358
+ Passing both raises ``ConflictingIdentityError``; passing neither raises
359
+ ``MissingIdentityError`` — in both cases no cloud query and no AI call is
360
+ made.
361
+
362
+ A call is also refused, before the cloud is asked, when it carries an option
363
+ that would make the request sent differ from the request proven: a body or
364
+ query rewrite (``extra_body``, ``extra_query``), a per-call destination
365
+ (``server_url``, ``http_options.base_url``), a header choosing the credential
366
+ or carrying the ACT token, or a Gemini tool the library would call on its
367
+ own. That is ``UnenforceableOptionError``; timeouts, retry policies and
368
+ ordinary headers pass. The client is held to the origin it had when it was
369
+ wrapped, and an authorized request never follows a redirect. Deriving a
370
+ client (``with_options()``, ``copy()``, ``with_middleware()``) is a closed
371
+ list as well: a new credential raises ``CredentialRoutingError``, a new
372
+ transport, query, middleware or unclassified option
373
+ ``UnsupportedProviderError``, and the derived client must keep the provider,
374
+ the origin and the credential headers of the one it came from.
375
+
376
+ ``credentials`` is how an account-level privacy setting becomes a per-user
377
+ one. No provider exposes training opt-out as a request parameter: the
378
+ posture lives on the credential — a project at OpenAI and Gemini, a
379
+ workspace at Anthropic, an organisation at Mistral — so one key imposes one
380
+ posture on every user of the app. Provision one credential per posture,
381
+ label each with the posture it carries, and the SDK sends each call on the
382
+ one this user's answer requires::
383
+
384
+ client = consent.wrap(
385
+ OpenAI(),
386
+ credentials={
387
+ "default": "proj_medhelp_default", # OpenAI-Project header
388
+ "zero_retention": "proj_medhelp_zdr",
389
+ },
390
+ )
391
+
392
+ client = consent.wrap(
393
+ Anthropic(), # no such header exists
394
+ credentials={"no_training": Anthropic(api_key=PRIVATE_WORKSPACE_KEY)},
395
+ )
396
+
397
+ A value is either an identifier for the provider's per-request credential
398
+ header (only OpenAI validates one, ``OpenAI-Project``) or a second client of
399
+ the same kind built with that credential's key. **You label; you do not
400
+ choose**: which posture a given consent state requires is policy, the cloud
401
+ answers it, and there is no callback to override it — that would hand the
402
+ developer the user's preferences (invariant #1). If the cloud names a
403
+ posture nothing carries, the call raises ``MissingCredentialError`` and
404
+ never goes out on another credential.
405
+
406
+ Args:
407
+ client: a supported provider client instance (e.g. ``OpenAI()``).
408
+ consent_client: override the cloud transport (mainly for testing). It
409
+ has to be a shape this client can wait for: a blocking provider
410
+ client is enforced on the calling thread and can await nothing, so
411
+ an async transport there is a ``TypeError`` at wrap time rather than
412
+ an ``AttributeError`` on a coroutine at the first AI call. The
413
+ reverse is fine — the async path takes a plain callable as it is.
414
+ adapter: override provider auto-detection.
415
+ base_url: override the ID Side API base URL (else the ``IDSIDE_BASE_URL``
416
+ environment variable, else the default).
417
+ api_key: ID Side API key for the cloud transport (else the
418
+ ``IDSIDE_API_KEY`` environment variable).
419
+ credentials: posture label → credential, for per-user credential
420
+ routing. Misconfiguration raises ``CredentialRoutingError`` here,
421
+ at wrap time, rather than on the request path.
422
+
423
+ Returns:
424
+ The same client, with its AI call method wrapped for consent enforcement.
425
+ """
426
+ return _wrap(
427
+ client,
428
+ consent_client=consent_client,
429
+ adapter=adapter,
430
+ base_url=base_url,
431
+ api_key=api_key,
432
+ credentials=credentials,
433
+ lineage=None,
434
+ )
435
+
436
+
437
+ def _wrap(
438
+ client: Any,
439
+ *,
440
+ consent_client: AnyConsentClient | None,
441
+ adapter: ProviderAdapter | None,
442
+ base_url: str | None,
443
+ api_key: str | None,
444
+ credentials: Mapping[str, Any] | None,
445
+ lineage: _Lineage | None,
446
+ ) -> Any:
447
+ """``wrap()``, plus the lineage of a derived client. See :func:`wrap`."""
448
+ resolved_adapter = adapter if adapter is not None else get_adapter_for(client)
449
+ call_sites = resolved_adapter.get_call_sites(client)
450
+
451
+ # What shape of client is this? Asked once, from the methods the descriptor
452
+ # declares, before anything is patched — an `AsyncOpenAI` answers yes because
453
+ # `chat.completions.create` is a coroutine function. It decides two things
454
+ # that must agree: which transport to the cloud is built, and which wrapper
455
+ # each call site gets.
456
+ methods = [getattr(target, name) for target, name, _site in call_sites]
457
+ # A repeated wrap must be inert all the way down to the transport guard. Its
458
+ # private provenance list otherwise retains one unusable closure per call.
459
+ # The completion bit is set only after call sites and derived-client methods
460
+ # have all been patched, so a failed construction is never mistaken for one.
461
+ # `methods` is never empty — `get_call_sites()` raises first — and the
462
+ # emptiness is named anyway: `all(())` is True, and the one thing this
463
+ # branch must never do is hand back an unwrapped client.
464
+ if methods and all(_is_completely_wrapped(method) for method in methods):
465
+ return client
466
+ is_async = any(_is_async_call_site(method) for method in methods)
467
+ descriptor = resolved_adapter.descriptor
468
+
469
+ # Where this client sends, checked before anything is patched. A derived
470
+ # client is held to its parent's facts and inherits the frozen origin; any
471
+ # other client has its own read off the attribute its library builds URLs
472
+ # from. A client that is the provider's own library object must name one:
473
+ # without it the net could only check the token, and a token that matches
474
+ # is exactly what a request sent elsewhere carries.
475
+ if lineage is not None:
476
+ _refuse_diverging_derivation(client, resolved_adapter, lineage)
477
+ refuse_declared_rewrites(client, descriptor)
478
+ # A client named in `credentials` sends the calls routed to it, so a rewrite
479
+ # declared on it rewrites those calls exactly as one declared on `client`.
480
+ # Anything but a mapping is the router's to refuse, with its own message.
481
+ alternatives = credentials.values() if isinstance(credentials, Mapping) else ()
482
+ for alternative in alternatives:
483
+ if not isinstance(alternative, str):
484
+ refuse_declared_rewrites(alternative, descriptor)
485
+ unguarded = _destination.unguarded_route(client)
486
+ if unguarded is not None:
487
+ raise UnsupportedProviderError(
488
+ f"This '{resolved_adapter.name}' client cannot be wrapped: {unguarded}. "
489
+ "The transport guard only sees requests sent on an HTTP client the "
490
+ "client holds, so a route outside one would reach the provider with "
491
+ "nothing to stop it. The client was not wrapped."
492
+ )
493
+ # Inherited by a derived client, never re-established on one — the same rule
494
+ # as the origin just below, and for the same reason. A derivation sends on
495
+ # its parent's transport and cannot bring another (`http_client=` is refused
496
+ # at the derivation), so whether that transport will take
497
+ # `follow_redirects` cannot have changed since the parent was wrapped.
498
+ # Walking the object graph again would put a walk per `with_options()` on the
499
+ # request path of an application that derives a client per call, to
500
+ # re-establish something already known.
501
+ unredirectable = _guard.unredirectable_route(client) if lineage is None else None
502
+ if unredirectable is not None:
503
+ raise UnsupportedProviderError(
504
+ f"This '{resolved_adapter.name}' client cannot be wrapped: "
505
+ f"{unredirectable}. A 307 or 308 is followed below the guard, where "
506
+ "nothing sees the second hop, and it carries the prompt, the ACT token "
507
+ "and the provider key to whatever host the redirect names. Sending "
508
+ "without the keyword is that risk; sending with it would fail the call "
509
+ "after ID Side had authorized it and recorded the proof. The client "
510
+ "was not wrapped: give it an HTTP client of a supported kind "
511
+ "(http_client=, client=, or http_options.httpx_client=), or report "
512
+ "its transport shape."
513
+ )
514
+ is_library_client = _is_library_client(client, descriptor)
515
+ origin = (
516
+ lineage.origin if lineage is not None else _destination.client_origin(client)
517
+ )
518
+ if origin is None and is_library_client:
519
+ raise UnsupportedProviderError(
520
+ f"The '{resolved_adapter.name}' client does not say where it sends "
521
+ "requests, so the transport guard could not hold its calls to that "
522
+ "destination. The client was not wrapped; use a supported client "
523
+ "version or report its shape."
524
+ )
525
+
526
+ # Resolved before anything is patched: an alternative client may be the
527
+ # wrapped one itself (`{"default": client}` is the honest way to say what
528
+ # the base key is), and capturing its method now is what stops a call routed
529
+ # back to it from re-entering enforcement.
530
+ router = (
531
+ CredentialRouter(
532
+ resolved_adapter.descriptor,
533
+ credentials,
534
+ [(site.id, (*site.path[:-1], method)) for _t, method, site in call_sites],
535
+ is_enforced=_is_enforced,
536
+ )
537
+ if credentials
538
+ else None
539
+ )
540
+ if credentials and any(isinstance(c, str) for c in credentials.values()):
541
+ _refuse_routing_header_in_defaults(client, descriptor)
542
+ # A derived client routes to its parent's clients, through the nets its
543
+ # parent already put on them; only a first wrap has clients to check.
544
+ routed = (
545
+ _routed_clients(client, credentials, resolved_adapter)
546
+ if credentials and lineage is None
547
+ else []
548
+ )
549
+
550
+ resolved_client: AnyConsentClient
551
+ if consent_client is not None:
552
+ _assert_consent_client_fits(consent_client, is_async)
553
+ resolved_client = consent_client
554
+ elif is_async:
555
+ # An ASGI application serves every other request in flight on the same
556
+ # event loop. A blocking hop to the cloud once per AI call would stall
557
+ # all of them, which is worse than the gap async support closes.
558
+ resolved_client = AsyncHttpConsentClient(base_url, api_key=api_key)
559
+ else:
560
+ # base_url/api_key may be None here; HttpConsentClient resolves them
561
+ # from IDSIDE_BASE_URL / IDSIDE_API_KEY, else its defaults.
562
+ resolved_client = HttpConsentClient(base_url, api_key=api_key)
563
+
564
+ # Require a complete net. Run this before patching call sites so a
565
+ # construction failure leaves them untouched.
566
+ transport_scope = _TransportScope()
567
+ wrap_state = _WrapState()
568
+ undo: list[Callable[[], None]] = []
569
+ guarded = _guard.install(
570
+ client, descriptor, _transport_provenance(transport_scope, origin), undo=undo
571
+ )
572
+ if guarded == "partial":
573
+ raise UnsupportedProviderError(
574
+ f"The '{resolved_adapter.name}' client exposes an HTTP transport, but "
575
+ "the transport guard could not cover every route. The client was not "
576
+ "wrapped; use a supported client version or report its transport shape."
577
+ )
578
+ # `False` means the search finished and found no transport. On the
579
+ # provider's own client that does not mean "nothing to guard": every
580
+ # supported library sends on an HTTP client it holds, so finding none means
581
+ # a version that builds it lazily or keeps it out of reach — every route
582
+ # unguarded. An object of another shape wrapped with an explicit adapter (a
583
+ # test double, an exotic provider) keeps the old answer: it holds no
584
+ # transport to leak through.
585
+ if guarded is False and is_library_client:
586
+ raise UnsupportedProviderError(
587
+ f"No HTTP transport was found on this '{resolved_adapter.name}' "
588
+ "client, so the transport guard could not be installed and nothing "
589
+ "would stop a request the enforced methods do not see. The client was "
590
+ "not wrapped; pass the client an explicit HTTP client (http_client=, "
591
+ "client=, or http_options.httpx_client=), or report its shape."
592
+ )
593
+ route_scopes = (
594
+ lineage.route_scopes
595
+ if lineage is not None
596
+ else _guard_routed_clients(routed, descriptor, undo)
597
+ )
598
+
599
+ for original, (target, method_name, site) in zip(methods, call_sites, strict=True):
600
+ if getattr(original, _ENFORCED_MARKER, False):
601
+ continue # already enforced: wrap() is idempotent
602
+ enforcer = _Enforcer(
603
+ resolved_adapter,
604
+ site.id,
605
+ (*site.path[:-1], method_name),
606
+ router,
607
+ site.resuming_params,
608
+ transport_scope,
609
+ route_scopes,
610
+ )
611
+ wrapped = _wrapper_for(
612
+ original,
613
+ enforcer,
614
+ resolved_client,
615
+ is_async,
616
+ defers_request=(
617
+ method_name in site.aliases and method_name in _DEFERRED_ALIASES
618
+ ),
619
+ )
620
+ setattr(wrapped, _ENFORCED_MARKER, wrap_state)
621
+ setattr(target, method_name, wrapped)
622
+
623
+ # A derived client is a new object: its call sites are unpatched, and it
624
+ # talks to the same provider. Re-wrap it on the way out — with the
625
+ # credentials as they are now: a client put into the caller's mapping after
626
+ # this line was never checked, and no net was put on it.
627
+ _keep_derived_enforced(
628
+ client,
629
+ resolved_adapter,
630
+ resolved_client,
631
+ dict(credentials) if credentials else credentials,
632
+ _Lineage(
633
+ provider=(
634
+ lineage.provider if lineage is not None else _detected_provider(client)
635
+ ),
636
+ origin=origin,
637
+ reserved_headers=_reserved_default_headers(client, descriptor),
638
+ route_scopes=route_scopes,
639
+ ),
640
+ )
641
+ # Existing methods can belong to an earlier interrupted attempt. A successful
642
+ # retry completes those states too: their scope predicate is still installed,
643
+ # while this attempt covered every remaining site and derived-client method.
644
+ for target, method_name, _site in call_sites:
645
+ state = getattr(getattr(target, method_name), _ENFORCED_MARKER, None)
646
+ if isinstance(state, _WrapState):
647
+ state.complete = True
648
+
649
+ # Half the support questions a wrapped client ever produces are "is it even
650
+ # being enforced?". This answers all of them at once: which descriptor
651
+ # matched, how many entry points were patched, which shape of client, and
652
+ # what the net came to: `true` means every supported route was guarded;
653
+ # `false` means the client exposed none. A partial result was already
654
+ # refused above. Posture labels are app configuration, so they
655
+ # can be named; the credentials behind them never appear.
656
+ _log.event(
657
+ "wrap",
658
+ provider=resolved_adapter.name,
659
+ call_sites=len(call_sites),
660
+ mode="async" if is_async else "blocking",
661
+ net=guarded,
662
+ postures=sorted(router.postures) if router is not None else None,
663
+ )
664
+ return client
665
+
666
+
667
+ def _assert_consent_client_fits(
668
+ consent_client: AnyConsentClient, is_async: bool
669
+ ) -> None:
670
+ """Refuse a cloud transport this client's shape has no way to wait for.
671
+
672
+ A blocking wrapper cannot await anything: handed an async ``authorize()`` it
673
+ passes the coroutine on as if it were a decision, and the developer gets an
674
+ ``AttributeError`` on a coroutine object, plus a "coroutine was never
675
+ awaited" warning raised somewhere else entirely. Fail-closed — no AI call
676
+ goes out — and undiagnosable, which is worth fixing on its own.
677
+
678
+ Checked at ``wrap()``, because that is the line that got it wrong and
679
+ because both facts are already in hand here. Only this direction is refused:
680
+ a *blocking* transport on an async client is a documented, working
681
+ combination (see ``_awaited``) — a test double or an application's own
682
+ implementation of the protocol is a plain callable more often than not.
683
+ """
684
+ if is_async or not _is_async_call_site(getattr(consent_client, "authorize", None)):
685
+ return
686
+ raise TypeError(
687
+ "consent.wrap() was given an async consent_client for a blocking "
688
+ "provider client. A blocking client is enforced on the calling thread "
689
+ "and has nothing to await the cloud with, so this would fail on the "
690
+ "first AI call, in a place that does not name the mistake. Pass a "
691
+ "blocking consent client (HttpConsentClient, or a synchronous double), "
692
+ "or wrap the provider's async client (AsyncOpenAI, AsyncAnthropic) — "
693
+ "wrap() then builds the async transport to the cloud itself."
694
+ )
695
+
696
+
697
+ def _is_library_client(client: Any, descriptor: ProviderDescriptor) -> bool:
698
+ """Whether ``client`` is an object of the provider's own client library.
699
+
700
+ The same test auto-detection makes, by module: ``openai._client.OpenAI``
701
+ belongs to ``openai``, ``google.genai.client.Client`` to ``google.genai``.
702
+ """
703
+ module = type(client).__module__
704
+ library = descriptor.client_module
705
+ return module == library or module.startswith(library + ".")
706
+
707
+
708
+ def _detected_provider(client: Any) -> str | None:
709
+ """The provider auto-detection names for ``client``, or None if it names none."""
710
+ try:
711
+ return get_adapter_for(client).name
712
+ except UnsupportedProviderError:
713
+ return None
714
+
715
+
716
+ def _reserved_default_headers(
717
+ client: Any, descriptor: ProviderDescriptor
718
+ ) -> tuple[tuple[str, Any], ...]:
719
+ """The reserved headers among those the client was built with.
720
+
721
+ Stainless clients keep the ``default_headers=`` they were given in
722
+ ``_custom_headers``, and merge it into every request with a case-sensitive
723
+ dict — so ``openai-project`` there sits beside a routed ``OpenAI-Project``
724
+ as a second header on the wire rather than giving way to it.
725
+ """
726
+ state = getattr(client, "__dict__", None)
727
+ custom = state.get("_custom_headers") if isinstance(state, dict) else None
728
+ if not isinstance(custom, Mapping):
729
+ return ()
730
+ return tuple(
731
+ (name, value)
732
+ for name, value in custom.items()
733
+ if isinstance(name, str) and descriptor.is_reserved_header(name)
734
+ )
735
+
736
+
737
+ def _refuse_routing_header_in_defaults(
738
+ client: Any, descriptor: ProviderDescriptor
739
+ ) -> None:
740
+ """Refuse a client built with the header credential routing has to set.
741
+
742
+ Routing by header adds ``OpenAI-Project`` to each call; the Stainless
743
+ clients merge that with ``default_headers=`` case-sensitively, so an
744
+ ``openai-project`` given at construction left two projects on one request.
745
+ Refused in every spelling, because routing is then the only thing that may
746
+ name the project: the unrouted default belongs in ``credentials=`` too.
747
+ """
748
+ routing = descriptor.credential_routing
749
+ header = routing.header.lower() if routing and routing.header else None
750
+ for name, _value in _reserved_default_headers(client, descriptor):
751
+ if name.lower() == header:
752
+ raise CredentialRoutingError(
753
+ f"This client was built with `{name}` in its default headers, and "
754
+ "consent.wrap(credentials=…) sets that header on every routed "
755
+ "call: the request would carry two, and the provider would choose. "
756
+ "Build the client without it and name that credential in "
757
+ "credentials= instead (the label 'default' is the usual one)."
758
+ )
759
+
760
+
761
+ def _routed_clients(
762
+ client: Any, credentials: Mapping[str, Any], adapter: ProviderAdapter
763
+ ) -> list[_RoutedClient]:
764
+ """The clients named in ``credentials``, each checked as ``client`` was.
765
+
766
+ A call routed to one of them goes out on it, so it is held to what the
767
+ wrapped client is held to — found here, before anything is patched: a route
768
+ outside any HTTP client the net can see, and, for the provider's own client,
769
+ no origin to freeze. Its origin is its own, not the wrapped client's: a
770
+ residency posture may live on another host (``eu.api.openai.com``).
771
+
772
+ ``client`` itself is left out, since its net is the wrapped client's. So is
773
+ a bare identifier, which only adds a header to a call ``client`` sends.
774
+ """
775
+ named: dict[int, tuple[list[str], Any]] = {}
776
+ for label, credential in credentials.items():
777
+ if isinstance(credential, str) or credential is client:
778
+ continue
779
+ named.setdefault(id(credential), ([], credential))[0].append(label)
780
+ routed: list[_RoutedClient] = []
781
+ for labels, credential in named.values():
782
+ unguarded = _destination.unguarded_route(credential)
783
+ if unguarded is not None:
784
+ raise UnsupportedProviderError(
785
+ f"The client passed as credential '{labels[0]}' cannot carry "
786
+ f"routed calls: {unguarded}. The transport guard only sees requests "
787
+ "sent on an HTTP client the client holds, so a call routed to it "
788
+ "would reach the provider with nothing to stop it. The client was "
789
+ "not wrapped."
790
+ )
791
+ unredirectable = _guard.unredirectable_route(credential)
792
+ if unredirectable is not None:
793
+ raise UnsupportedProviderError(
794
+ f"The client passed as credential '{labels[0]}' cannot carry "
795
+ f"routed calls: {unredirectable}. A call routed to it is an "
796
+ "authorized one leaving on its transport, so it is held to what the "
797
+ "wrapped client is held to: a redirect below the guard would carry "
798
+ "the prompt, the ACT token and that credential's key to a host "
799
+ f"nobody wrapped. The '{adapter.name}' client was not wrapped; give "
800
+ "that client an HTTP client of a supported kind, or report its "
801
+ "transport shape."
802
+ )
803
+ origin = _destination.client_origin(credential)
804
+ if origin is None and _is_library_client(credential, adapter.descriptor):
805
+ raise UnsupportedProviderError(
806
+ f"The client passed as credential '{labels[0]}' does not say where "
807
+ "it sends requests, so the transport guard could not hold the calls "
808
+ f"routed to it to that destination. The '{adapter.name}' client was "
809
+ "not wrapped; use a supported client version or report its shape."
810
+ )
811
+ routed.append(_RoutedClient(tuple(labels), credential, origin))
812
+ return routed
813
+
814
+
815
+ def _guard_routed_clients(
816
+ routed: list[_RoutedClient],
817
+ descriptor: ProviderDescriptor,
818
+ undo: list[Callable[[], None]],
819
+ ) -> dict[str, _TransportScope]:
820
+ """Put the net on every routed client, or undo this wrap's nets and refuse.
821
+
822
+ Each client gets a scope of its own, and a routed call's permit is minted
823
+ for that scope: on a transport two clients share (``copy()`` shares it), the
824
+ wrapped client's calls are then held to its origin and the routed client's
825
+ to its own, never to either. The criterion is the wrapped client's — a route
826
+ the walk could not finish, a transport that refused the patch, or no
827
+ transport at all on the provider's own client — and so is the outcome:
828
+ nothing this wrap patched stays patched.
829
+ """
830
+ scopes: dict[str, _TransportScope] = {}
831
+ for labels, routed_client, origin in routed:
832
+ scope = _TransportScope()
833
+ net = _guard.install(
834
+ routed_client,
835
+ descriptor,
836
+ _transport_provenance(scope, origin),
837
+ undo=undo,
838
+ )
839
+ if net == "partial" or (
840
+ net is False and _is_library_client(routed_client, descriptor)
841
+ ):
842
+ for step in reversed(undo):
843
+ step()
844
+ raise UnsupportedProviderError(
845
+ f"The transport guard could not cover every route of the client "
846
+ f"passed as credential '{labels[0]}', so a call routed to it could "
847
+ "reach the provider with nothing to stop it. The client was not "
848
+ "wrapped; pass that client an explicit HTTP client (http_client=, "
849
+ "client=, or http_options.httpx_client=), or report its shape."
850
+ )
851
+ scopes.update(dict.fromkeys(labels, scope))
852
+ return scopes
853
+
854
+
855
+ def _refuse_diverging_derivation(
856
+ derived: Any, adapter: ProviderAdapter, lineage: _Lineage
857
+ ) -> None:
858
+ """Hold a derived client to what its parent was wrapped as, or refuse it.
859
+
860
+ Raised at the derivation, before the derived client exists for the caller
861
+ and before anything is sent — the line that changed the destination is the
862
+ line that fails. ``UnsupportedProviderError`` rather than a routing error:
863
+ a client bound for another origin, another provider or another credential
864
+ is not the client ``wrap()`` agreed to enforce, which is what that error
865
+ means everywhere else, while ``CredentialRoutingError`` describes a
866
+ ``credentials=`` mapping and would send its reader to the wrong place.
867
+ """
868
+ name = adapter.name
869
+ if lineage.provider is not None:
870
+ detected = _detected_provider(derived)
871
+ if detected != lineage.provider:
872
+ became = f"identified as '{detected}'" if detected else "not identifiable"
873
+ raise UnsupportedProviderError(
874
+ f"A client derived from a consent-enforced '{lineage.provider}' "
875
+ f"client is {became}. The provider tells ID Side whose rules apply "
876
+ "to the call; derive only clients of the same provider, and wrap a "
877
+ "client of another one on its own."
878
+ )
879
+ if lineage.origin is not None and (
880
+ _destination.client_origin(derived) != lineage.origin
881
+ ):
882
+ raise UnsupportedProviderError(
883
+ f"A client derived from a consent-enforced '{name}' client must send "
884
+ f"to the same origin ({lineage.origin}), and this one does "
885
+ "not: its calls would carry the prompt, the ACT token and the key "
886
+ "somewhere nobody wrapped. Build and wrap a separate client for "
887
+ "another destination."
888
+ )
889
+ if _reserved_default_headers(derived, adapter.descriptor) != (
890
+ lineage.reserved_headers
891
+ ):
892
+ raise CredentialRoutingError(
893
+ f"A client derived from a consent-enforced '{name}' client must keep "
894
+ "the credential headers it was built with — none added, none replaced, "
895
+ "none dropped: they choose the credential or carry the consent token, "
896
+ "and credential routing is what sets them. Build the client with the "
897
+ "headers it needs, or route with consent.wrap(client, credentials={…})."
898
+ )
899
+
900
+
901
+ def _keep_derived_enforced(
902
+ client: Any,
903
+ adapter: ProviderAdapter,
904
+ consent_client: AnyConsentClient,
905
+ credentials: Mapping[str, Any] | None,
906
+ lineage: _Lineage,
907
+ ) -> None:
908
+ """Make ``with_options()``/``copy()`` hand back an enforced client.
909
+
910
+ The derived client is wrapped with the *same* adapter and consent client,
911
+ and its own deriving methods are patched by the same code path, so a chain
912
+ of them stays enforced all the way down. ``credentials`` is re-resolved
913
+ against the new client rather than carried over: an alternative client is a
914
+ separate object the derivation never touched, and re-checking it costs a few
915
+ attribute lookups at derive time instead of a silently stale route.
916
+ """
917
+ for name in _DERIVING_METHODS:
918
+ derive = getattr(client, name, None)
919
+ if not callable(derive) or getattr(derive, _ENFORCED_MARKER, False):
920
+ continue
921
+ setattr(
922
+ client,
923
+ name,
924
+ _make_deriving(
925
+ client, derive, name, adapter, consent_client, credentials, lineage
926
+ ),
927
+ )
928
+
929
+
930
+ def _make_deriving(
931
+ client: Any,
932
+ original: Callable[..., Any],
933
+ method: str,
934
+ adapter: ProviderAdapter,
935
+ consent_client: AnyConsentClient,
936
+ credentials: Mapping[str, Any] | None,
937
+ lineage: _Lineage,
938
+ ) -> Callable[..., Any]:
939
+ """Wrap a client-deriving method so its result comes back enforced.
940
+
941
+ The arguments are judged before the library builds the derived client, and
942
+ the derived client against its parent once it exists; either refusal
943
+ happens before anything is sent. The parent goes to the argument check
944
+ because one option — middleware — is a *change* to what the parent runs, and
945
+ can only be judged against it.
946
+ """
947
+
948
+ @functools.wraps(original)
949
+ def _deriving(*args: Any, **kwargs: Any) -> Any:
950
+ refuse_underivable(adapter.descriptor, method, args, kwargs, client)
951
+ return _wrap(
952
+ original(*args, **kwargs),
953
+ consent_client=consent_client,
954
+ adapter=adapter,
955
+ base_url=None,
956
+ api_key=None,
957
+ credentials=credentials,
958
+ lineage=lineage,
959
+ )
960
+
961
+ setattr(_deriving, _ENFORCED_MARKER, True)
962
+ return _deriving
963
+
964
+
965
+ def _resolve_route(
966
+ router: CredentialRouter | None,
967
+ posture: str | None,
968
+ adapter: ProviderAdapter,
969
+ route_key: tuple[str, ...],
970
+ ) -> Route:
971
+ """Find the credential this call must go out on, or refuse to make it.
972
+
973
+ ``posture`` is ``None`` for almost every call: the cloud names one only when
974
+ the user's answer requires a credential other than the one the client was
975
+ built with.
976
+ """
977
+ if posture is None:
978
+ return NO_ROUTE
979
+ if router is None:
980
+ raise MissingCredentialError(
981
+ missing_message(adapter.descriptor, posture, ()),
982
+ posture=posture,
983
+ provider=adapter.name,
984
+ )
985
+ return router.route(posture, route_key)
986
+
987
+
988
+ def _takes_call_site(apply_constraints: Callable[..., Any]) -> bool:
989
+ """Whether an adapter's ``apply_constraints`` accepts the ``call_site`` keyword.
990
+
991
+ ``call_site`` was added after custom adapters were documented as
992
+ ``apply_constraints(kwargs, constraints)``. Passing it to one of those raised
993
+ ``TypeError`` after the cloud had authorized the call — a proof in the ledger
994
+ for a call that was never made. Such an adapter is called as it was written,
995
+ and reads what it always read.
996
+ """
997
+ try:
998
+ parameters = inspect.signature(apply_constraints).parameters.values()
999
+ except (TypeError, ValueError):
1000
+ return False
1001
+ return any(
1002
+ parameter.name == "call_site" or parameter.kind is inspect.Parameter.VAR_KEYWORD
1003
+ for parameter in parameters
1004
+ )
1005
+
1006
+
1007
+ def _assert_routed(
1008
+ adapter: ProviderAdapter, kwargs: dict[str, Any], headers: dict[str, str]
1009
+ ) -> None:
1010
+ """Refuse to call the provider if the routed credential did not land.
1011
+
1012
+ The adapter is what places headers on a call, and a custom one is free to
1013
+ override that. Verifying rather than trusting costs a dict lookup and turns
1014
+ the one failure that would be invisible — a call leaving on the credential
1015
+ routing was meant to move it off — into an error.
1016
+ """
1017
+ applied = kwargs.get(adapter.descriptor.headers_kwarg)
1018
+ if isinstance(applied, Mapping) and all(
1019
+ applied.get(name) == value for name, value in headers.items()
1020
+ ):
1021
+ return
1022
+ raise CredentialRoutingError(
1023
+ f"The '{adapter.name}' adapter did not carry the routed credential onto "
1024
+ "this call, so the AI call was not made. An adapter overriding apply() "
1025
+ "must forward its credential_headers argument."
1026
+ )
1027
+
1028
+
1029
+ class _Enforcer:
1030
+ """Everything a patched method does around the one question put to the cloud.
1031
+
1032
+ The blocking wrapper, the async one and the deferred one differ by where the
1033
+ ``await`` goes and by nothing else. What surrounds it — consuming the
1034
+ identity, hashing the request, honouring the constraints, routing the
1035
+ credential, injecting the forced parameters and the token — lives here, once.
1036
+ That is not tidiness: a parameter one path forced and another forgot would be
1037
+ a user's refusal silently not applied on whichever client shape they happened
1038
+ to be served by, and neither wrapper could show it on its own.
1039
+
1040
+ ``call_site`` is the endpoint's stable id, closed over per patched method. It
1041
+ travels to the cloud on every authorize so the decision is made for the
1042
+ endpoint actually being called: ``background`` is a retention lever on
1043
+ ``/v1/responses`` and a TypeError on ``chat.completions``.
1044
+
1045
+ ``route_key`` is the attribute path of *this* method, aliases included. It is
1046
+ what credential routing looks a call up by, because two methods sharing a
1047
+ wire id are still two different methods: a call made on ``messages.parse``
1048
+ must land on the alternative client's ``parse``, not on its ``create``.
1049
+
1050
+ ``router`` holds the developer's labelled credentials, when they provisioned
1051
+ several. It decides *which* credential carries the call; the cloud decides
1052
+ which posture that has to be.
1053
+
1054
+ ``resuming_params`` are the keyword arguments that mean this call resumes a
1055
+ result instead of creating one — the descriptor's answer for an overloaded
1056
+ method, and empty for every site that has only one form.
1057
+
1058
+ ``route_scopes`` name, by posture label, the net of each client a call can
1059
+ be routed to. A routed call is sent by that client, on its transport, so its
1060
+ permit is minted for that client's scope and not for ``transport_scope``.
1061
+ """
1062
+
1063
+ __slots__ = (
1064
+ "adapter",
1065
+ "call_site",
1066
+ "passes_call_site",
1067
+ "resuming_params",
1068
+ "route_key",
1069
+ "route_scopes",
1070
+ "router",
1071
+ "transport_scope",
1072
+ )
1073
+
1074
+ def __init__(
1075
+ self,
1076
+ adapter: ProviderAdapter,
1077
+ call_site: str,
1078
+ route_key: tuple[str, ...],
1079
+ router: CredentialRouter | None,
1080
+ resuming_params: tuple[str, ...] = (),
1081
+ transport_scope: object | None = None,
1082
+ route_scopes: Mapping[str, object] | None = None,
1083
+ ) -> None:
1084
+ self.adapter = adapter
1085
+ self.call_site = call_site
1086
+ # Read once: the adapter is fixed for the life of this wrapper, and
1087
+ # `inspect.signature` on every AI call is work repeated for nothing.
1088
+ self.passes_call_site = _takes_call_site(adapter.apply_constraints)
1089
+ self.route_key = route_key
1090
+ self.router = router
1091
+ self.resuming_params = resuming_params
1092
+ self.transport_scope = (
1093
+ transport_scope if transport_scope is not None else object()
1094
+ )
1095
+ self.route_scopes = route_scopes or {}
1096
+
1097
+ def resumes(self, kwargs: dict[str, Any]) -> dict[str, Any] | None:
1098
+ """The kwargs to pass straight through, or ``None`` for a real call.
1099
+
1100
+ A client method can be overloaded: ``responses.stream(response_id=…)``
1101
+ replays a response that already exists over ``GET /v1/responses/{id}``,
1102
+ where ``responses.stream(model=…, input=…)`` creates one over POST. The
1103
+ resuming form carries no prompt and no model. Enforcing it demanded an
1104
+ identity for a call that creates nothing, authorized with an empty model
1105
+ — and signed a token asserting an AI call that never happened.
1106
+
1107
+ So it is let through, and letting it through is safe rather than
1108
+ trusted: nothing here decides that no request will be made, only that
1109
+ this SDK will not certify one. A call passed through carries no ACT
1110
+ token, so if it turns out to be body-bearing after all, the net refuses
1111
+ it before it leaves the process. The identity arguments are consumed as
1112
+ they are everywhere else — the provider has never heard of them.
1113
+ """
1114
+ marker = next(
1115
+ (name for name in self.resuming_params if kwargs.get(name) is not None),
1116
+ None,
1117
+ )
1118
+ if marker is None:
1119
+ return None
1120
+ descriptor = self.adapter.descriptor
1121
+ # Said out loud: a provider call with no authorize beside it is exactly
1122
+ # what a leak looks like in a journal, and this one is not a leak.
1123
+ _log.event("resume", call_site=self.call_site, marker=marker)
1124
+ return {
1125
+ key: value
1126
+ for key, value in kwargs.items()
1127
+ if key not in (descriptor.identity_param, descriptor.ubt_param)
1128
+ }
1129
+
1130
+ def is_internal_call(self, kwargs: dict[str, Any]) -> bool:
1131
+ """True only for a nested hop of the currently authorized call.
1132
+
1133
+ An alias may defer to a method this SDK also patched
1134
+ (``messages.parse`` calls ``messages.create``). The public header cannot
1135
+ prove that happened. The outer wrapper creates a one-shot context-local
1136
+ permit after authorization and keeps it active across awaited and
1137
+ deferred provider operations. Matching the call site and token binds the
1138
+ nested hop to that exact call.
1139
+ """
1140
+ carried = kwargs.get(self.adapter.descriptor.headers_kwarg)
1141
+ token = (
1142
+ carried.get(self.adapter.descriptor.token_header)
1143
+ if isinstance(carried, Mapping)
1144
+ else None
1145
+ )
1146
+ permit = _INTERNAL_CALL.get()
1147
+ internal = permit is not None and permit.consume(
1148
+ self.transport_scope, self.call_site, token
1149
+ )
1150
+ if internal:
1151
+ # Said out loud, because the alternative is a journal in which one
1152
+ # provider call has no authorize next to it and looks like the leak
1153
+ # this SDK exists to prevent.
1154
+ _log.event("passthrough", call_site=self.call_site)
1155
+ return internal
1156
+
1157
+ @contextmanager
1158
+ def internal_call(self, decision: ConsentDecision) -> Any:
1159
+ """Scope a one-shot nested-hop permit to this provider invocation.
1160
+
1161
+ Yields the permit so a lazily produced result can keep it in force for
1162
+ the I/O it has not done yet — see :func:`_lazily_permitted`.
1163
+
1164
+ The permit belongs to the client that sends the call: the one routing
1165
+ named when a posture is carried by another client, the wrapped one
1166
+ otherwise — a header route included, since that call leaves on it.
1167
+ """
1168
+ posture = decision.credential_posture
1169
+ scope = (
1170
+ self.transport_scope
1171
+ if posture is None
1172
+ else self.route_scopes.get(posture, self.transport_scope)
1173
+ )
1174
+ permit = _InternalCall(self.call_site, decision.act_token, scope)
1175
+ context_token = _INTERNAL_CALL.set(permit)
1176
+ try:
1177
+ yield permit
1178
+ finally:
1179
+ _INTERNAL_CALL.reset(context_token)
1180
+
1181
+ def begin(self, kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
1182
+ """Consume the identity and hash the request; ask nothing of anybody.
1183
+
1184
+ Pure on purpose. It runs before the cloud is reached on every path, so a
1185
+ call that names no user — or two — fails where the mistake was made,
1186
+ including on a streaming helper whose real request only happens later.
1187
+ So does a call carrying an option that would undo enforcement once the
1188
+ cloud has answered (``extra_body``, a per-call destination, a header
1189
+ choosing the credential): asking first would put a proof in the ledger
1190
+ for a request that was never going to be the one sent. Checked before
1191
+ the hash, too, since some of those options cannot be hashed at all.
1192
+
1193
+ Returns the arguments for ``authorize()`` and the call kwargs that
1194
+ survive, the identity removed.
1195
+ """
1196
+ descriptor = self.adapter.descriptor
1197
+ remaining = dict(kwargs)
1198
+ user_id = remaining.pop(descriptor.identity_param, None)
1199
+ ubt = remaining.pop(descriptor.ubt_param, None)
1200
+ # Blank names nobody, as empty already did; nothing else is trimmed.
1201
+ user_id = user_id if _named(user_id) else None
1202
+ ubt = ubt if _named(ubt) else None
1203
+ if user_id and ubt:
1204
+ raise ConflictingIdentityError(
1205
+ "Pass either user_id or ubt on a consent-enforced AI call, "
1206
+ "not both: the UBT already names the user."
1207
+ )
1208
+ if not user_id and not ubt:
1209
+ raise MissingIdentityError(
1210
+ "user_id or ubt is required for consent-enforced AI calls."
1211
+ )
1212
+ refuse_unenforceable(descriptor, self.call_site, remaining)
1213
+ meta = self.adapter.extract_request_metadata(**remaining)
1214
+ authorize_kwargs = {
1215
+ "user_id": user_id,
1216
+ "ubt": ubt,
1217
+ "provider": self.adapter.name,
1218
+ "model": meta["model"],
1219
+ "request_hash": meta["request_hash"],
1220
+ "call_site": self.call_site,
1221
+ "credential_postures": (
1222
+ self.router.postures if self.router is not None else ()
1223
+ ),
1224
+ }
1225
+ return authorize_kwargs, remaining
1226
+
1227
+ def journal_authorized(
1228
+ self, authorize_kwargs: dict[str, Any], decision: ConsentDecision, ms: int
1229
+ ) -> None:
1230
+ """Say what the cloud answered, in terms that name nobody.
1231
+
1232
+ Every field here is either a fact about the developer's own request or
1233
+ already a digest. The subject is a salted pseudonym, the request hash is
1234
+ truncated, and three things the decision carries are deliberately
1235
+ absent: the ACT token (bearer material), the raw identity, and
1236
+ ``consent_state`` — the person's stated preference, which answers no
1237
+ debugging question that ``forced`` does not answer better.
1238
+ """
1239
+ _log.event(
1240
+ "authorize",
1241
+ call_site=self.call_site,
1242
+ model=authorize_kwargs["model"],
1243
+ subject=_log.pseudonym(
1244
+ authorize_kwargs["ubt"] or authorize_kwargs["user_id"]
1245
+ ),
1246
+ binding="ubt" if authorize_kwargs["ubt"] else "user_id",
1247
+ request_hash=_log.short_hash(authorize_kwargs["request_hash"]),
1248
+ duration_ms=ms,
1249
+ forced=sorted(decision.params_forced),
1250
+ constraints=sorted(decision.constraints) or None,
1251
+ posture=decision.credential_posture,
1252
+ )
1253
+
1254
+ def journal_authorize_failed(
1255
+ self, authorize_kwargs: dict[str, Any], exc: BaseException, ms: int
1256
+ ) -> None:
1257
+ """The class of what went wrong and how long it took, never its message.
1258
+
1259
+ An exception message is written for a human and can quote whatever it
1260
+ was handed — a URL with a query string, a header, the body it failed to
1261
+ parse. The type is the part that is safe to emit and the part that
1262
+ answers "is this a config problem or an outage?"; the message reaches
1263
+ the developer anyway, on the exception they are about to catch.
1264
+ """
1265
+ _log.event(
1266
+ "authorize_failed",
1267
+ call_site=self.call_site,
1268
+ model=authorize_kwargs["model"],
1269
+ subject=_log.pseudonym(
1270
+ authorize_kwargs["ubt"] or authorize_kwargs["user_id"]
1271
+ ),
1272
+ duration_ms=ms,
1273
+ error=type(exc).__name__,
1274
+ )
1275
+
1276
+ def finish(
1277
+ self, kwargs: dict[str, Any], decision: ConsentDecision
1278
+ ) -> tuple[Callable[..., Any] | None, dict[str, Any]]:
1279
+ """Apply the cloud's answer, and say which callable must make the call.
1280
+
1281
+ Returns the alternative client's method when credential routing named
1282
+ one — the same endpoint on the credential's own client, for the providers
1283
+ where a key is fixed at construction and there is no header to switch it
1284
+ — or ``None``, meaning the method that was patched.
1285
+ """
1286
+ adapter = self.adapter
1287
+ try:
1288
+ # Constraints first: a refused call must not be dressed with a token
1289
+ # and forced parameters on its way to being rejected.
1290
+ allowed_kwargs = (
1291
+ adapter.apply_constraints(
1292
+ kwargs, decision.constraints, call_site=self.call_site
1293
+ )
1294
+ if self.passes_call_site
1295
+ else adapter.apply_constraints(kwargs, decision.constraints)
1296
+ )
1297
+ route = _resolve_route(
1298
+ self.router, decision.credential_posture, adapter, self.route_key
1299
+ )
1300
+ except Exception as exc:
1301
+ # A local refusal after a healthy authorize. Without this line the
1302
+ # journal shows an authorize that succeeded and then nothing at all,
1303
+ # which is the most confusing shape a journal can have. Every class,
1304
+ # not only `IDSideError`: the Node wrapper catches everything here,
1305
+ # and a journal that goes quiet on a TypeError from a provider's own
1306
+ # argument handling is the same confusing shape by another route.
1307
+ # `UnenforcedCallError` derives from BaseException and is raised by
1308
+ # the net, not from here, so it is deliberately out of reach.
1309
+ _log.event(
1310
+ "refused",
1311
+ call_site=self.call_site,
1312
+ error=type(exc).__name__,
1313
+ posture=decision.credential_posture,
1314
+ )
1315
+ raise
1316
+ enforced_kwargs = adapter.apply(
1317
+ allowed_kwargs,
1318
+ decision.params_forced,
1319
+ decision.act_token,
1320
+ credential_headers=route.headers,
1321
+ )
1322
+ if route.headers:
1323
+ _assert_routed(adapter, enforced_kwargs, route.headers)
1324
+ _log.event(
1325
+ "enforced",
1326
+ call_site=self.call_site,
1327
+ forced=sorted(decision.params_forced),
1328
+ routed=bool(route.headers or route.call),
1329
+ )
1330
+ return route.call, enforced_kwargs
1331
+
1332
+
1333
+ def _wrapper_for(
1334
+ original: Callable[..., Any],
1335
+ enforcer: _Enforcer,
1336
+ consent_client: AnyConsentClient,
1337
+ is_async: bool,
1338
+ *,
1339
+ defers_request: bool,
1340
+ ) -> Callable[..., Any]:
1341
+ """Pick the enforcement wrapper this method's own shape can carry.
1342
+
1343
+ Four shapes, and the choice is forced by what the method returns and when it
1344
+ sends, never by preference:
1345
+
1346
+ - a coroutine function (``AsyncOpenAI.chat.completions.create``) takes the
1347
+ async wrapper;
1348
+ - anything else on an async client is a helper that hands back a context
1349
+ manager and defers the real request to ``__aenter__`` — every provider's
1350
+ ``stream()``. Wrapping it in a coroutine would break ``async with``, so
1351
+ the enforcement is deferred with it;
1352
+ - a helper on a BLOCKING client that defers its request the same way
1353
+ (``defers_request``, i.e. a ``stream()`` alias) is deferred the same way
1354
+ too. It reads like the eager case and is not one: the request happens in
1355
+ ``__enter__``, so that is where the proof belongs. The two client shapes
1356
+ would otherwise put a different number of entries in one customer's
1357
+ ledger for the same abandoned stream;
1358
+ - a plain method on a blocking client takes the blocking wrapper.
1359
+ """
1360
+ if _is_async_call_site(original):
1361
+ return _make_async_enforced(original, enforcer, consent_client)
1362
+ if is_async:
1363
+ return _make_deferred_enforced(
1364
+ original, enforcer, consent_client, _EnforcedAsyncContext
1365
+ )
1366
+ if defers_request:
1367
+ return _make_deferred_enforced(
1368
+ original, enforcer, consent_client, _EnforcedSyncContext
1369
+ )
1370
+ # A blocking client is only ever paired with a blocking transport to the
1371
+ # cloud: `wrap()` decides both from the same answer, and an async
1372
+ # `consent_client=` override is refused at wrap time.
1373
+ return _make_enforced(original, enforcer, cast(ConsentClient, consent_client))
1374
+
1375
+
1376
+ def _make_enforced(
1377
+ original: Callable[..., Any],
1378
+ enforcer: _Enforcer,
1379
+ consent_client: ConsentClient,
1380
+ ) -> Callable[..., Any]:
1381
+ """The blocking wrapper: authorize, then call, on this thread."""
1382
+
1383
+ @functools.wraps(original)
1384
+ def _enforced(**kwargs: Any) -> Any:
1385
+ if enforcer.is_internal_call(kwargs):
1386
+ return original(**kwargs)
1387
+ resumed = enforcer.resumes(kwargs)
1388
+ if resumed is not None:
1389
+ return original(**resumed)
1390
+ authorize_kwargs, call_kwargs = enforcer.begin(kwargs)
1391
+ try:
1392
+ with _log.timer() as elapsed:
1393
+ decision = consent_client.authorize(**authorize_kwargs)
1394
+ except BaseException as exc:
1395
+ enforcer.journal_authorize_failed(authorize_kwargs, exc, elapsed.ms)
1396
+ raise
1397
+ enforcer.journal_authorized(authorize_kwargs, decision, elapsed.ms)
1398
+ call, enforced_kwargs = enforcer.finish(call_kwargs, decision)
1399
+ with enforcer.internal_call(decision) as permit:
1400
+ return _lazily_permitted(permit, (call or original)(**enforced_kwargs))
1401
+
1402
+ setattr(_enforced, _ENFORCED_MARKER, True)
1403
+ return _enforced
1404
+
1405
+
1406
+ def _make_async_enforced(
1407
+ original: Callable[..., Any],
1408
+ enforcer: _Enforcer,
1409
+ consent_client: AnyConsentClient,
1410
+ ) -> Callable[..., Any]:
1411
+ """The async wrapper: the same steps, yielding the loop at the cloud hop.
1412
+
1413
+ ``await`` on the provider call too, and not only on the cloud: the caller
1414
+ awaits this coroutine and must receive what the provider returned, not a
1415
+ coroutine of it.
1416
+ """
1417
+
1418
+ @functools.wraps(original)
1419
+ async def _enforced(**kwargs: Any) -> Any:
1420
+ if enforcer.is_internal_call(kwargs):
1421
+ return await original(**kwargs)
1422
+ resumed = enforcer.resumes(kwargs)
1423
+ if resumed is not None:
1424
+ return await original(**resumed)
1425
+ authorize_kwargs, call_kwargs = enforcer.begin(kwargs)
1426
+ try:
1427
+ with _log.timer() as elapsed:
1428
+ decision = await _awaited(consent_client.authorize(**authorize_kwargs))
1429
+ except BaseException as exc:
1430
+ enforcer.journal_authorize_failed(authorize_kwargs, exc, elapsed.ms)
1431
+ raise
1432
+ enforcer.journal_authorized(authorize_kwargs, decision, elapsed.ms)
1433
+ call, enforced_kwargs = enforcer.finish(call_kwargs, decision)
1434
+ with enforcer.internal_call(decision) as permit:
1435
+ return _lazily_permitted(
1436
+ permit, await (call or original)(**enforced_kwargs)
1437
+ )
1438
+
1439
+ setattr(_enforced, _ENFORCED_MARKER, True)
1440
+ return _enforced
1441
+
1442
+
1443
+ def _make_deferred_enforced(
1444
+ original: Callable[..., Any],
1445
+ enforcer: _Enforcer,
1446
+ consent_client: AnyConsentClient,
1447
+ context: type[_DeferredEnforcement],
1448
+ ) -> Callable[..., Any]:
1449
+ """The wrapper for a helper that defers its request to a context manager.
1450
+
1451
+ ``AsyncOpenAI.chat.completions.stream`` is not a coroutine function, and
1452
+ ``OpenAI.chat.completions.stream`` is not an ordinary method: both return a
1453
+ context manager, and the request happens when that manager is entered.
1454
+ Patching the async one with a coroutine would hand every caller a
1455
+ Promise-shaped object where ``async with`` expects a manager — the same
1456
+ reason the Node SDK does not patch ``stream`` at all.
1457
+
1458
+ So the enforcement splits along the same seam the helper already has: the
1459
+ identity check and the request hash happen here, at the call, where a
1460
+ mistake belongs; the cloud is asked when the manager is entered, next to the
1461
+ request it authorizes. ``context`` is which of the two managers this client
1462
+ shape can carry, and it is the only thing that differs between them.
1463
+ """
1464
+
1465
+ @functools.wraps(original)
1466
+ def _deferred(**kwargs: Any) -> Any:
1467
+ if enforcer.is_internal_call(kwargs):
1468
+ return original(**kwargs)
1469
+ resumed = enforcer.resumes(kwargs)
1470
+ if resumed is not None:
1471
+ # The provider's own manager, untouched: this form makes no AI call,
1472
+ # so there is nothing to defer and nothing to certify.
1473
+ return original(**resumed)
1474
+ authorize_kwargs, call_kwargs = enforcer.begin(kwargs)
1475
+ return context(
1476
+ original, enforcer, consent_client, authorize_kwargs, call_kwargs
1477
+ )
1478
+
1479
+ setattr(_deferred, _ENFORCED_MARKER, True)
1480
+ return _deferred
1481
+
1482
+
1483
+ class _DeferredEnforcement:
1484
+ """What a deferred helper carries from the call to the moment it is entered.
1485
+
1486
+ Stands in for what ``stream()`` returns and delegates to it, once the cloud
1487
+ has answered. Entering is the only way to get anything out of it, which is
1488
+ also the only way the provider's own manager works: **a stream nobody
1489
+ entered made no request, so it needed no proof.**
1490
+
1491
+ It is deliberately not a proxy: nothing else on it is forwarded to the
1492
+ provider's manager, so there is no route to the provider that does not go
1493
+ through the authorize below. The net on the transport is the other half of
1494
+ that guarantee — a request built any other way carries no ACT token and is
1495
+ refused before it leaves the process.
1496
+ """
1497
+
1498
+ __slots__ = (
1499
+ "_authorize_kwargs",
1500
+ "_call_kwargs",
1501
+ "_consent_client",
1502
+ "_enforcer",
1503
+ "_manager",
1504
+ "_original",
1505
+ )
1506
+
1507
+ def __init__(
1508
+ self,
1509
+ original: Callable[..., Any],
1510
+ enforcer: _Enforcer,
1511
+ consent_client: AnyConsentClient,
1512
+ authorize_kwargs: dict[str, Any],
1513
+ call_kwargs: dict[str, Any],
1514
+ ) -> None:
1515
+ self._original = original
1516
+ self._enforcer = enforcer
1517
+ self._consent_client = consent_client
1518
+ self._authorize_kwargs = authorize_kwargs
1519
+ self._call_kwargs = call_kwargs
1520
+ self._manager: Any = None
1521
+
1522
+ def _prepare_open(
1523
+ self, decision: ConsentDecision
1524
+ ) -> tuple[Callable[..., Any], dict[str, Any]]:
1525
+ """Apply the cloud's answer and return the provider manager factory."""
1526
+ call, enforced_kwargs = self._enforcer.finish(self._call_kwargs, decision)
1527
+ return call or self._original, enforced_kwargs
1528
+
1529
+
1530
+ class _EnforcedSyncContext(_DeferredEnforcement):
1531
+ """The blocking half: ``with client.chat.completions.stream(...) as s``."""
1532
+
1533
+ __slots__ = ()
1534
+
1535
+ def __enter__(self) -> Any:
1536
+ enforcer = self._enforcer
1537
+ client = cast(ConsentClient, self._consent_client)
1538
+ try:
1539
+ with _log.timer() as elapsed:
1540
+ decision = client.authorize(**self._authorize_kwargs)
1541
+ except BaseException as exc:
1542
+ enforcer.journal_authorize_failed(self._authorize_kwargs, exc, elapsed.ms)
1543
+ raise
1544
+ enforcer.journal_authorized(self._authorize_kwargs, decision, elapsed.ms)
1545
+ call, enforced_kwargs = self._prepare_open(decision)
1546
+ with enforcer.internal_call(decision) as permit:
1547
+ self._manager = call(**enforced_kwargs)
1548
+ return _lazily_permitted(permit, self._manager.__enter__())
1549
+
1550
+ def __exit__(self, *exc_info: Any) -> Any:
1551
+ # Unreachable with a None manager: Python does not call __exit__ when
1552
+ # __enter__ raised, and nothing else can set it.
1553
+ return self._manager.__exit__(*exc_info)
1554
+
1555
+
1556
+ class _EnforcedAsyncContext(_DeferredEnforcement):
1557
+ """The async half: ``async with client.chat.completions.stream(...) as s``."""
1558
+
1559
+ __slots__ = ()
1560
+
1561
+ async def __aenter__(self) -> Any:
1562
+ enforcer = self._enforcer
1563
+ try:
1564
+ with _log.timer() as elapsed:
1565
+ decision = await _awaited(
1566
+ self._consent_client.authorize(**self._authorize_kwargs)
1567
+ )
1568
+ except BaseException as exc:
1569
+ enforcer.journal_authorize_failed(self._authorize_kwargs, exc, elapsed.ms)
1570
+ raise
1571
+ enforcer.journal_authorized(self._authorize_kwargs, decision, elapsed.ms)
1572
+ call, enforced_kwargs = self._prepare_open(decision)
1573
+ with enforcer.internal_call(decision) as permit:
1574
+ self._manager = call(**enforced_kwargs)
1575
+ return _lazily_permitted(permit, await self._manager.__aenter__())
1576
+
1577
+ async def __aexit__(self, *exc_info: Any) -> Any:
1578
+ # Unreachable with a None manager: Python does not call __aexit__ when
1579
+ # __aenter__ raised, and nothing else can set it.
1580
+ return await self._manager.__aexit__(*exc_info)