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/__about__.py +5 -0
- idside/__init__.py +158 -0
- idside/_destination.py +165 -0
- idside/_guard.py +573 -0
- idside/_jcs.py +130 -0
- idside/_log.py +163 -0
- idside/_routing.py +280 -0
- idside/_transport.py +831 -0
- idside/adapters/__init__.py +110 -0
- idside/adapters/_call_options.py +859 -0
- idside/adapters/base.py +462 -0
- idside/adapters/gemini.py +89 -0
- idside/consent-options.json +207 -0
- idside/consent.py +1580 -0
- idside/exceptions.py +481 -0
- idside/options.py +297 -0
- idside/providers/__init__.py +252 -0
- idside/providers/anthropic.json +108 -0
- idside/providers/deepseek.json +108 -0
- idside/providers/gemini.json +130 -0
- idside/providers/mistral.json +195 -0
- idside/providers/openai.json +217 -0
- idside/py.typed +0 -0
- idside/verify.py +776 -0
- idside-0.1.0.dev0.dist-info/METADATA +473 -0
- idside-0.1.0.dev0.dist-info/RECORD +29 -0
- idside-0.1.0.dev0.dist-info/WHEEL +4 -0
- idside-0.1.0.dev0.dist-info/licenses/LICENSE +350 -0
- idside-0.1.0.dev0.dist-info/licenses/NOTICE +29 -0
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)
|