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/_transport.py ADDED
@@ -0,0 +1,831 @@
1
+ # Patents: https://sdk.idside.eu/patents
2
+ # This software may be covered by the patents and applications listed there.
3
+
4
+ """Transport to the ID Side cloud.
5
+
6
+ The SDK never signs ACT tokens itself (the private ECDSA keys live in the cloud).
7
+ It asks the cloud to authorize a request: the cloud resolves the user's
8
+ preferences AND returns a signed ACT token bound to this exact request.
9
+
10
+ This module is internal (``_transport``). The public entry point is
11
+ :func:`idside.consent.wrap`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Final, Protocol
21
+
22
+ import httpx
23
+
24
+ from idside.__about__ import __version__
25
+ from idside.exceptions import (
26
+ AuthenticationError,
27
+ BindingError,
28
+ BindingRequiredError,
29
+ ConflictingIdentityError,
30
+ ConsentDeniedError,
31
+ ConsentRefreshRequiredError,
32
+ ConsentUnreachableError,
33
+ ContractError,
34
+ InvalidBindingError,
35
+ MissingIdentityError,
36
+ QuotaExceededError,
37
+ RateLimitedError,
38
+ )
39
+
40
+ DEFAULT_BASE_URL = "https://sdk.idside.eu"
41
+ _AUTHORIZE_PATH = "/v1/authorize"
42
+
43
+ #: Appended to an outage-shaped failure when the address was the default: with
44
+ #: neither ``base_url=`` nor ``IDSIDE_BASE_URL``, a developer who forgot the
45
+ #: variable read ``Could not verify consent (HTTP 404)`` and went looking for a
46
+ #: network fault. Word for word the Node SDK's sentence.
47
+ _DEFAULT_ORIGIN_HINT = (
48
+ f"No ID Side address was configured, so the SDK used its default, "
49
+ f"{DEFAULT_BASE_URL}: set IDSIDE_BASE_URL to the address shown in your ID "
50
+ f"Side dashboard, or pass that address explicitly."
51
+ )
52
+
53
+ #: Identifies the SDK to the cloud on every request (mirrors the Node SDK).
54
+ _USER_AGENT = f"idside-python/{__version__}"
55
+
56
+ #: The deadline the authorize hop gets, in seconds — one number, shared with the
57
+ #: Node SDK, which applies the same one to its `fetch`.
58
+ #:
59
+ #: On a fail-closed path a timeout is not a tuning knob: it is the moment the
60
+ #: product decides the cloud is dead and stops the caller's AI calls. Two SDKs
61
+ #: with two numbers is one network incident cutting a Python application and
62
+ #: sparing its Node equivalent, for no reason a customer could be told.
63
+ #:
64
+ #: Five, not ten, because waiting is what costs here: the call is a small JSON
65
+ #: POST (a lookup and a signature) whose healthy p99 is far under a second, and
66
+ #: the alternative to waiting is the documented refusal this SDK exists to
67
+ #: raise, never a silent pass. An authorize hop still open after five seconds is
68
+ #: already an incident.
69
+ #:
70
+ #: Every phase gets the same value rather than a tighter connect budget: a
71
+ #: budget that varies by phase makes the moment of refusal depend on *how* the
72
+ #: cloud is failing, and `fetch` cannot express the distinction, so keeping it
73
+ #: on this side was keeping half a contract the other SDK could not sign.
74
+ DEFAULT_TIMEOUT_SECONDS: Final = 5.0
75
+
76
+ _DEFAULT_TIMEOUT = httpx.Timeout(DEFAULT_TIMEOUT_SECONDS)
77
+
78
+ #: 403 problem+json ``type`` values the cloud uses to qualify a denial.
79
+ #:
80
+ #: Only what the cloud actually sends belongs here. ``provider_blocked`` sat in
81
+ #: this set for months and was never emitted by anything: a reason the SDK
82
+ #: names but nothing produces is a branch a caller writes and never takes, and
83
+ #: dead code that reads as tested behaviour. Adding one back is a cloud change
84
+ #: first, an SDK change second.
85
+ #:
86
+ #: ``policy_hold`` comes from an account in good standing: an organization's policy
87
+ #: could not apply to this one call, and the application is set to hold such
88
+ #: calls. An SDK that predates it still raises the denial, without the reason.
89
+ _DENY_REASONS = frozenset({"fraud_suspended", "organization_suspended", "policy_hold"})
90
+
91
+ #: RFC 9457 says `type` is a URI, and the cloud sends one. This SDK used to
92
+ #: compare it against the bare names above, so `reason` was ALWAYS None — for
93
+ #: `fraud_suspended` too — while the documentation showed callers branching on
94
+ #: it. The denial itself always worked; only the explanation was missing, which
95
+ #: is why it went unnoticed.
96
+ _PROBLEM_TYPE_PREFIX = "https://idside.eu/errors/"
97
+
98
+
99
+ def _problem_type_name(type_value: Any) -> str:
100
+ """The error name a problem `type` carries, ``""`` if it is not a string.
101
+
102
+ Callers compare the result against a CLOSED set of names, and that is what
103
+ keeps the reading closed: a `type` from another host, a deeper path under
104
+ our prefix (`…/errors/v2/<name>`) or a name this SDK does not know never
105
+ equals one of them, so it qualifies nothing. The bare form is still
106
+ accepted so an older cloud — or a test double — keeps working.
107
+ """
108
+ if not isinstance(type_value, str):
109
+ return ""
110
+ return type_value.removeprefix(_PROBLEM_TYPE_PREFIX)
111
+
112
+
113
+ def _deny_reason(type_value: Any) -> str | None:
114
+ """Qualify a 403 from its problem `type`, or None if we cannot."""
115
+ name = _problem_type_name(type_value)
116
+ return name if name in _DENY_REASONS else None
117
+
118
+
119
+ #: The two 401s that are about the visitor, not the key, with what to do. Both
120
+ #: names are the cloud's (`ubt_invalid`: any UBT that does not verify — expired,
121
+ #: issued for another app, altered, or not a UBT; `ubt_required`: a strict app
122
+ #: called with `user_id`). Any other
123
+ #: 401 — `invalid_api_key`, a `type` this SDK does not know, no body at all —
124
+ #: stays a plain AuthenticationError: a value nobody classified never moves a
125
+ #: refusal into a class that says the key is fine.
126
+ _BINDING_ERRORS: dict[str, tuple[type[BindingError], str]] = {
127
+ "ubt_invalid": (
128
+ InvalidBindingError,
129
+ "ID Side refused this visitor's binding (HTTP 401): the UBT did not "
130
+ "verify — it expired, was issued for another application, was altered, "
131
+ "or is not a UBT at all. Your API key was accepted. "
132
+ "Discard the UBT stored in this visitor's session and let the widget "
133
+ "issue a new one before retrying; the AI call was not made.",
134
+ ),
135
+ "ubt_required": (
136
+ BindingRequiredError,
137
+ "This application requires a UBT (HTTP 401), and the call named its "
138
+ "user with user_id. Your API key was accepted. Have the visitor go "
139
+ "through the widget and pass the UBT it issues as ubt before any AI "
140
+ "call; the AI call was not made.",
141
+ ),
142
+ }
143
+
144
+
145
+ #: A posture is a label a developer types into their own configuration. Anything
146
+ #: longer is not one, and this value is quoted back in error messages.
147
+ _MAX_POSTURE_LENGTH = 200
148
+ _MAX_ACT_TOKEN_LENGTH = 8_192
149
+ _COMPACT_JWS = re.compile(r"[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")
150
+ _TOKEN_ID = re.compile(r"act_[0-9a-z]+")
151
+ _RFC3339 = re.compile(
152
+ r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
153
+ r"[Tt](?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})"
154
+ r"(?:\.\d+)?(?:[Zz]|(?P<offset_sign>[+-])(?P<offset_hour>\d{2}):"
155
+ r"(?P<offset_minute>\d{2}))"
156
+ )
157
+ _BINDINGS = frozenset({"ubt", "user_id"})
158
+
159
+ #: What the cloud counts as blank — ActiveSupport's ``blank?``, the Unicode
160
+ #: White_Space characters — and nothing wider: the 25 code points below, the
161
+ #: same list the Node SDK holds. ``str.strip()`` would not do: it disagrees with
162
+ #: both the cloud and JavaScript's ``trim()`` on U+FEFF, U+0085 and U+001C-001F.
163
+ _BLANK = re.compile(
164
+ r"[\t\n\v\f\r \x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+"
165
+ )
166
+
167
+
168
+ def _is_blank(value: str) -> bool:
169
+ """Whether ``value`` is made only of what the cloud counts as blank."""
170
+ return _BLANK.fullmatch(value) is not None
171
+
172
+
173
+ def _named(identity: Any) -> bool:
174
+ """Whether an identity argument names somebody.
175
+
176
+ Empty, ``None``, ``0`` and ``False`` never did; a string made only of
177
+ blanks does not either — the cloud refuses it with a 422. Nothing is
178
+ trimmed: an identity that names somebody is sent exactly as written,
179
+ because its pseudonym is derived from those characters.
180
+ """
181
+ return bool(identity) and not (isinstance(identity, str) and _is_blank(identity))
182
+
183
+
184
+ _TOOL_CONSTRAINTS = frozenset({"refuse", "strip"})
185
+
186
+
187
+ def _problem_body(response: httpx.Response) -> dict[str, Any]:
188
+ """Best-effort parse of an RFC 9457 problem+json body; ``{}`` if unusable."""
189
+ try:
190
+ data = response.json()
191
+ except ValueError:
192
+ return {}
193
+ return data if isinstance(data, dict) else {}
194
+
195
+
196
+ def _credential_posture(value: Any) -> str | None:
197
+ """Validate the routed posture from an authorize response.
198
+
199
+ Null and absent mean the same thing — the client as constructed is fine.
200
+ Anything else that is not a plain, printable, reasonably short label is
201
+ treated as a response the SDK cannot act on, and a decision it cannot act on
202
+ is not a decision it may ignore: ignoring it would send the call on the
203
+ credential routing existed to move it off.
204
+ """
205
+ if value is None:
206
+ return None
207
+ if (
208
+ not isinstance(value, str)
209
+ or not value.strip()
210
+ or len(value) > _MAX_POSTURE_LENGTH
211
+ or not value.isprintable()
212
+ ):
213
+ raise ConsentUnreachableError(
214
+ "ID Side cloud named a credential posture the SDK cannot use "
215
+ "(credential_posture must be a short, printable string); the AI call "
216
+ "was not made."
217
+ )
218
+ return value
219
+
220
+
221
+ def _invalid_decision(field_name: str, expected: str) -> ConsentUnreachableError:
222
+ """Describe an unusable successful response without echoing its value."""
223
+ return ConsentUnreachableError(
224
+ "ID Side cloud returned a malformed authorize response "
225
+ f"(field {field_name} must be {expected}); the AI call was not made."
226
+ )
227
+
228
+
229
+ def _decision_object(
230
+ data: dict[str, Any], field_name: str, *, required: bool = False
231
+ ) -> dict[str, Any]:
232
+ """Read a JSON object field, defaulting only optional absent fields."""
233
+ if field_name not in data:
234
+ if required:
235
+ raise KeyError(field_name)
236
+ return {}
237
+ value = data[field_name]
238
+ if not isinstance(value, dict):
239
+ raise _invalid_decision(field_name, "a JSON object")
240
+ return value
241
+
242
+
243
+ def _decision_string(data: dict[str, Any], field_name: str) -> str:
244
+ """Read a required, non-empty printable string field."""
245
+ value = data[field_name]
246
+ if not isinstance(value, str) or not value.strip() or not value.isprintable():
247
+ raise _invalid_decision(field_name, "a non-empty printable string")
248
+ return value
249
+
250
+
251
+ def _act_token(data: dict[str, Any]) -> str:
252
+ """Read the compact-JWS-shaped token returned by authorize."""
253
+ token = _decision_string(data, "act_token")
254
+ if len(token) > _MAX_ACT_TOKEN_LENGTH or _COMPACT_JWS.fullmatch(token) is None:
255
+ raise _invalid_decision("act_token", "a compact JWS")
256
+ return token
257
+
258
+
259
+ def _token_id(data: dict[str, Any]) -> str:
260
+ """Read the public identifier of the ACT token."""
261
+ token_id = _decision_string(data, "token_id")
262
+ if _TOKEN_ID.fullmatch(token_id) is None:
263
+ raise _invalid_decision("token_id", "an ACT token identifier")
264
+ return token_id
265
+
266
+
267
+ def _expires_at(data: dict[str, Any]) -> str:
268
+ """Read a calendar-valid RFC 3339 timestamp."""
269
+ value = _decision_string(data, "expires_at")
270
+ match = _RFC3339.fullmatch(value)
271
+ if match is None:
272
+ raise _invalid_decision("expires_at", "an RFC 3339 date-time")
273
+ parts = {
274
+ name: int(raw)
275
+ for name, raw in match.groupdict().items()
276
+ if raw and name != "offset_sign"
277
+ }
278
+ year = parts["year"]
279
+ month = parts["month"]
280
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
281
+ month_days = (
282
+ 31,
283
+ 29 if leap else 28,
284
+ 31,
285
+ 30,
286
+ 31,
287
+ 30,
288
+ 31,
289
+ 31,
290
+ 30,
291
+ 31,
292
+ 30,
293
+ 31,
294
+ )
295
+ valid = (
296
+ 1 <= month <= 12
297
+ and 1 <= parts["day"] <= month_days[month - 1]
298
+ and parts["hour"] <= 23
299
+ and parts["minute"] <= 59
300
+ and parts["second"] <= 60
301
+ and parts.get("offset_hour", 0) <= 23
302
+ and parts.get("offset_minute", 0) <= 59
303
+ )
304
+ if not valid:
305
+ raise _invalid_decision("expires_at", "an RFC 3339 date-time")
306
+ return value
307
+
308
+
309
+ def _constraints(data: dict[str, Any]) -> dict[str, Any]:
310
+ """Read only constraints this SDK version can apply safely."""
311
+ constraints = _decision_object(data, "constraints")
312
+ unknown = set(constraints) - {"tools"}
313
+ if unknown:
314
+ raise _invalid_decision("constraints", "an object understood by this SDK")
315
+ if "tools" in constraints and (
316
+ not isinstance(constraints["tools"], str)
317
+ or constraints["tools"] not in _TOOL_CONSTRAINTS
318
+ ):
319
+ raise _invalid_decision("constraints.tools", "the string 'refuse' or 'strip'")
320
+ return constraints
321
+
322
+
323
+ def _with_problem_details(message: str, problem: dict[str, Any]) -> str:
324
+ """Append the problem+json ``title``/``detail`` to ``message`` if present.
325
+
326
+ The cloud writes its ``detail`` as a sentence, so it usually brings its own
327
+ full stop: adding another printed ``…expired)..``.
328
+ """
329
+ parts = [
330
+ value
331
+ for key in ("title", "detail")
332
+ if isinstance(value := problem.get(key), str) and value
333
+ ]
334
+ if not parts:
335
+ return message
336
+ said = "; ".join(parts)
337
+ end = "" if said.endswith((".", "!", "?")) else "."
338
+ return f"{message} Cloud says: {said}{end}"
339
+
340
+
341
+ @dataclass(frozen=True)
342
+ class ConsentDecision:
343
+ """The cloud's authorization decision for a single AI request.
344
+
345
+ ``binding`` records how strongly the end user was tied to this request —
346
+ it is the evidentiary weight of the proof, visible to an auditor:
347
+
348
+ - ``"ubt"`` — the identity came from a User Binding Token: the ID Side
349
+ widget saw this user and the cloud verified the signed binding.
350
+ - ``"user_id"`` — the developer asserted the identity; accepted, but the
351
+ weaker link (nothing ties the id passed to the SDK to the person who
352
+ actually consented).
353
+ """
354
+
355
+ consent_state: dict[str, Any]
356
+ params_forced: dict[str, Any]
357
+ act_token: str
358
+ source: str
359
+ legal_basis: str
360
+ binding: str
361
+ #: Non-parameter restrictions the SDK must apply to the outgoing call —
362
+ #: today only ``{"tools": "refuse" | "strip"}``. Separate from
363
+ #: ``params_forced`` on purpose: a forced parameter sets a value, and the
364
+ #: cloud is never allowed to write the developer's request. A constraint
365
+ #: rejects a call instead, which is a different kind of authority.
366
+ #:
367
+ #: It is returned from the user's consent state alone: the cloud is not told
368
+ #: whether this call carries tools, and could not be without learning
369
+ #: something about the request.
370
+ constraints: dict[str, Any] = field(default_factory=dict)
371
+ #: The label of the credential this call must go out on, or ``None`` when
372
+ #: the client as constructed is fine. It is the answer to a policy question
373
+ #: — *which privacy posture does this user's consent require?* — which is
374
+ #: why the cloud answers it and the SDK only matches the label against what
375
+ #: the developer provisioned. Naming one that was not provisioned fails the
376
+ #: call (``MissingCredentialError``): the cloud must name what the answer
377
+ #: requires, never the best of what happens to be available.
378
+ credential_posture: str | None = None
379
+
380
+
381
+ class ConsentClient(Protocol):
382
+ """Interface the SDK uses to authorize a request against the cloud."""
383
+
384
+ def authorize(
385
+ self,
386
+ *,
387
+ provider: str,
388
+ model: str,
389
+ request_hash: str,
390
+ user_id: str | None = None,
391
+ ubt: str | None = None,
392
+ call_site: str | None = None,
393
+ credential_postures: Sequence[str] = (),
394
+ ) -> ConsentDecision: ...
395
+
396
+
397
+ class AsyncConsentClient(Protocol):
398
+ """The same interface, for the client an async provider client is wrapped with.
399
+
400
+ Separate rather than a union member with an ``async`` flag: the two are told
401
+ apart by which provider client was wrapped, at ``wrap()`` time, and never on
402
+ the request path. An application serving other requests on the same event
403
+ loop cannot afford a blocking hop to the cloud once per AI call — that is
404
+ worse than the problem async support exists to solve.
405
+ """
406
+
407
+ async def authorize(
408
+ self,
409
+ *,
410
+ provider: str,
411
+ model: str,
412
+ request_hash: str,
413
+ user_id: str | None = None,
414
+ ubt: str | None = None,
415
+ call_site: str | None = None,
416
+ credential_postures: Sequence[str] = (),
417
+ ) -> ConsentDecision: ...
418
+
419
+
420
+ #: Whitespace the HTTP layer drops around a header value — what ``fetch`` drops
421
+ #: in the Node SDK — and the characters a key may hold once it is dropped.
422
+ _HTTP_WHITESPACE = "\t\n\r "
423
+ _SENDABLE_KEY = re.compile(r"[\x21-\x7e]+")
424
+
425
+ _UNSENDABLE_KEY = (
426
+ "The ID Side API key cannot be sent: it contains a character an HTTP header "
427
+ "cannot carry (a space, a tab or a line break inside it, or a character "
428
+ "outside printable ASCII), often left by the file or variable it was read "
429
+ "from. Check the api_key passed to consent.wrap() and IDSIDE_API_KEY; the "
430
+ "key itself is not repeated here."
431
+ )
432
+
433
+
434
+ def _sendable_key(api_key: str | None) -> str | None:
435
+ """The key as it will be sent, ``None`` when there is none.
436
+
437
+ Whitespace around it is dropped. Anything else a header cannot carry is
438
+ refused here, before a request exists: sent as it was, the HTTP layer
439
+ rejected it with an error quoting the whole header — key included — chained
440
+ under an "unreachable" that sent the developer looking for an outage.
441
+ """
442
+ key = (api_key or "").strip(_HTTP_WHITESPACE)
443
+ if not key:
444
+ return None
445
+ if _SENDABLE_KEY.fullmatch(key) is None:
446
+ raise ValueError(_UNSENDABLE_KEY)
447
+ return key
448
+
449
+
450
+ def _resolved_config(
451
+ base_url: str | None, api_key: str | None
452
+ ) -> tuple[str, dict[str, str], str]:
453
+ """Resolve base URL and headers from args then environment.
454
+
455
+ ``base_url``: explicit argument, else ``IDSIDE_BASE_URL``, else the default.
456
+ ``api_key``: explicit argument, else ``IDSIDE_API_KEY``, else none (no
457
+ ``Authorization`` header). An empty-string env var is treated as absent,
458
+ and so is a key made only of whitespace; whitespace around a key is
459
+ dropped, and a key a header cannot carry raises ``ValueError`` here.
460
+ The third value is the sentence an outage-shaped failure carries when the
461
+ default was used, ``""`` when an address was given.
462
+ """
463
+ configured = base_url or os.environ.get("IDSIDE_BASE_URL")
464
+ resolved_api_key = _sendable_key(api_key or os.environ.get("IDSIDE_API_KEY"))
465
+ headers = {"User-Agent": _USER_AGENT}
466
+ if resolved_api_key:
467
+ headers["Authorization"] = f"Bearer {resolved_api_key}"
468
+ if configured:
469
+ return configured, headers, ""
470
+ return DEFAULT_BASE_URL, headers, _DEFAULT_ORIGIN_HINT
471
+
472
+
473
+ def _authorize_payload(
474
+ *,
475
+ provider: str,
476
+ model: str,
477
+ request_hash: str,
478
+ user_id: str | None,
479
+ ubt: str | None,
480
+ call_site: str | None,
481
+ credential_postures: Sequence[str],
482
+ ) -> dict[str, Any]:
483
+ """Build the ``/v1/authorize`` body, refusing an identity that is not one.
484
+
485
+ Shared by the blocking and the async client, and deliberately: what the
486
+ cloud is told about a request must not depend on which shape of provider
487
+ client the developer happens to hold.
488
+ """
489
+ user_id = user_id if _named(user_id) else None
490
+ ubt = ubt if _named(ubt) else None
491
+ if user_id and ubt:
492
+ raise ConflictingIdentityError(
493
+ "Pass either user_id or ubt to authorize(), not both."
494
+ )
495
+ if not user_id and not ubt:
496
+ raise MissingIdentityError(
497
+ "authorize() needs an identity: pass user_id or ubt."
498
+ )
499
+ identity = {"ubt": ubt} if ubt else {"user_id": user_id}
500
+ payload: dict[str, Any] = {
501
+ **identity,
502
+ "provider": provider,
503
+ "model": model,
504
+ "request_hash": request_hash,
505
+ "credential_postures": list(credential_postures),
506
+ }
507
+ if call_site:
508
+ payload["call_site"] = call_site
509
+ return payload
510
+
511
+
512
+ def _decision_from(response: httpx.Response, origin_hint: str) -> ConsentDecision:
513
+ """Turn one authorize response into a decision, or fail closed.
514
+
515
+ Also shared: a 403 that denies a blocking call must deny the async one, and
516
+ a body the SDK cannot fully parse is "consent not verified" on both paths.
517
+ ``origin_hint`` is the sentence an unexpected status carries when the
518
+ address was the default, ``""`` otherwise.
519
+ """
520
+ status = response.status_code
521
+ if status >= httpx.codes.BAD_REQUEST:
522
+ _raise_for_status(status, response, origin_hint)
523
+
524
+ # Defensive validation: a 200 the SDK cannot fully parse is treated as
525
+ # "consent not verified" (fail-closed) — a decision is never guessed.
526
+ try:
527
+ data = response.json()
528
+ except ValueError as exc:
529
+ raise ConsentUnreachableError(
530
+ "ID Side cloud returned a malformed authorize response "
531
+ "(invalid JSON); the AI call was not made."
532
+ ) from exc
533
+ if not isinstance(data, dict):
534
+ raise ConsentUnreachableError(
535
+ "ID Side cloud returned a malformed authorize response "
536
+ "(not a JSON object); the AI call was not made."
537
+ )
538
+ try:
539
+ if data["decision"] != "allow":
540
+ raise _invalid_decision("decision", "'allow'")
541
+ consent_state = data["consent_state"]
542
+ if not isinstance(consent_state, dict):
543
+ raise _invalid_decision("consent_state", "a JSON object")
544
+ binding = _decision_string(data, "binding")
545
+ if binding not in _BINDINGS:
546
+ raise _invalid_decision("binding", "'ubt' or 'user_id'")
547
+ mode = data["mode"]
548
+ if not isinstance(mode, str) or mode not in {"test", "live"}:
549
+ raise _invalid_decision("mode", "'test' or 'live'")
550
+ source = _decision_string(data, "source")
551
+ legal_basis = _decision_string(data, "legal_basis")
552
+ _token_id(data)
553
+ _expires_at(data)
554
+ return ConsentDecision(
555
+ consent_state=consent_state,
556
+ params_forced=_decision_object(data, "params_forced", required=True),
557
+ act_token=_act_token(data),
558
+ source=source,
559
+ legal_basis=legal_basis,
560
+ binding=binding,
561
+ constraints=_constraints(data),
562
+ credential_posture=_credential_posture(data.get("credential_posture")),
563
+ )
564
+ except KeyError as exc:
565
+ raise ConsentUnreachableError(
566
+ f"ID Side cloud returned an incomplete authorize response "
567
+ f"(missing field {exc}); the AI call was not made."
568
+ ) from exc
569
+
570
+
571
+ def _raise_for_status(status: int, response: httpx.Response, origin_hint: str) -> None:
572
+ """Map an error response to the typed exception hierarchy.
573
+
574
+ A configuration problem (401/402/422/429) must be distinguishable from
575
+ an outage; everything genuinely undetermined stays fail-closed
576
+ (``ConsentUnreachableError``). No AI call goes out in any of these cases.
577
+ """
578
+ problem = _problem_body(response)
579
+ if status == httpx.codes.UNAUTHORIZED:
580
+ binding = _BINDING_ERRORS.get(_problem_type_name(problem.get("type")))
581
+ if binding is not None:
582
+ error_class, message = binding
583
+ raise error_class(_with_problem_details(message, problem))
584
+ raise AuthenticationError(
585
+ _with_problem_details(
586
+ "ID Side rejected the API credentials (HTTP 401). Check the "
587
+ "api_key passed to consent.wrap() against the ID Side "
588
+ "dashboard; the AI call was not made.",
589
+ problem,
590
+ )
591
+ )
592
+ if status == httpx.codes.PAYMENT_REQUIRED:
593
+ raise QuotaExceededError(
594
+ _with_problem_details(
595
+ "ID Side plan quota exceeded (HTTP 402). Upgrade the plan or "
596
+ "wait for the quota window to reset; the AI call was not made.",
597
+ problem,
598
+ )
599
+ )
600
+ if status == httpx.codes.FORBIDDEN:
601
+ type_value = problem.get("type")
602
+ reason = _deny_reason(type_value)
603
+ raise ConsentDeniedError(
604
+ _with_problem_details(
605
+ "The requested AI usage was denied (HTTP 403); "
606
+ "the AI call was not made.",
607
+ problem,
608
+ ),
609
+ reason=reason,
610
+ )
611
+ if (
612
+ status == httpx.codes.CONFLICT
613
+ and _problem_type_name(problem.get("type")) == "consent_refresh_required"
614
+ ):
615
+ # Not an outage: the visitor's binding still verifies, and what it
616
+ # covers is out of date. Any other 409 stays undetermined, below.
617
+ raise ConsentRefreshRequiredError(
618
+ _with_problem_details(
619
+ "This visitor's recorded choices no longer cover this "
620
+ "application's AI use (HTTP 409): the policy or the questions "
621
+ "it requires changed since they answered. Show them the widget "
622
+ "again before retrying; the AI call was not made.",
623
+ problem,
624
+ )
625
+ )
626
+ if status == httpx.codes.UNPROCESSABLE_ENTITY:
627
+ raise ContractError(
628
+ _with_problem_details(
629
+ "ID Side refused this call's payload (HTTP 422). If the cloud's "
630
+ "detail names a value your code passed, fix that value; "
631
+ "otherwise the SDK and the cloud disagree on the contract — "
632
+ "upgrade the SDK or report it; the AI call was not made.",
633
+ problem,
634
+ )
635
+ )
636
+ if status == httpx.codes.TOO_MANY_REQUESTS:
637
+ raise RateLimitedError(
638
+ _with_problem_details(
639
+ "ID Side rate limit hit (HTTP 429). Retry after a backoff; "
640
+ "the AI call was not made.",
641
+ problem,
642
+ )
643
+ )
644
+ # 5xx or any undetermined 4xx: consent could not be verified → fail-closed.
645
+ raise ConsentUnreachableError(
646
+ _with_hint(
647
+ _with_problem_details(
648
+ f"Could not verify consent (HTTP {status}); the AI call was not made.",
649
+ problem,
650
+ ),
651
+ origin_hint,
652
+ )
653
+ )
654
+
655
+
656
+ def _with_hint(message: str, origin_hint: str) -> str:
657
+ return f"{message} {origin_hint}" if origin_hint else message
658
+
659
+
660
+ _UNREACHABLE = "ID Side cloud is unreachable; the AI call was not made."
661
+
662
+
663
+ class HttpConsentClient:
664
+ """Default :class:`ConsentClient` backed by ``idside.eu`` (httpx)."""
665
+
666
+ def __init__(
667
+ self,
668
+ base_url: str | None = None,
669
+ *,
670
+ api_key: str | None = None,
671
+ timeout: httpx.Timeout | float | None = None,
672
+ transport: httpx.BaseTransport | None = None,
673
+ ) -> None:
674
+ """Build the transport, resolving config from args then environment.
675
+
676
+ ``base_url``: explicit argument, else ``IDSIDE_BASE_URL``, else the
677
+ default. ``api_key``: explicit argument, else ``IDSIDE_API_KEY``, else
678
+ none (no ``Authorization`` header). An empty-string env var is treated
679
+ as absent. Every request carries a ``User-Agent: idside-python/<ver>``
680
+ header. ``timeout`` overrides the shared 5 s default
681
+ (:data:`DEFAULT_TIMEOUT_SECONDS`, the same number the Node SDK uses).
682
+ """
683
+ resolved_base_url, headers, self._origin_hint = _resolved_config(
684
+ base_url, api_key
685
+ )
686
+ self._client = httpx.Client(
687
+ base_url=resolved_base_url,
688
+ timeout=timeout if timeout is not None else _DEFAULT_TIMEOUT,
689
+ transport=transport,
690
+ headers=headers,
691
+ )
692
+
693
+ def authorize(
694
+ self,
695
+ *,
696
+ provider: str,
697
+ model: str,
698
+ request_hash: str,
699
+ user_id: str | None = None,
700
+ ubt: str | None = None,
701
+ call_site: str | None = None,
702
+ credential_postures: Sequence[str] = (),
703
+ ) -> ConsentDecision:
704
+ """Ask the cloud to authorize one AI request.
705
+
706
+ Exactly one of ``user_id`` or ``ubt`` names the end user. A UBT (User
707
+ Binding Token) is opaque to the SDK: it is forwarded as-is and the
708
+ cloud alone verifies its signature and binding.
709
+
710
+ ``call_site`` names the endpoint about to be called (``responses.create``,
711
+ ``chat.completions.create``…). It is *mechanics*, not policy: the cloud
712
+ needs it because a provider's endpoints do not accept the same
713
+ parameters, and forcing one where it does not exist breaks the call for
714
+ exactly the users who opted out. Omitted when unknown, in which case the
715
+ cloud falls back to what is safe on every endpoint of that provider.
716
+
717
+ ``credential_postures`` lists the posture labels the developer
718
+ provisioned for this provider (see ``credentials=`` on
719
+ :func:`idside.consent.wrap`). It is sent on every request, empty list
720
+ included, because its *presence* is what tells the cloud this SDK
721
+ understands routing at all: an older one sends no such field and would
722
+ ignore a posture it was handed, which is the one case where the cloud
723
+ must not count on the call being routed. Labels are app configuration,
724
+ never credentials — the credential itself never leaves the process.
725
+ """
726
+ payload = _authorize_payload(
727
+ provider=provider,
728
+ model=model,
729
+ request_hash=request_hash,
730
+ user_id=user_id,
731
+ ubt=ubt,
732
+ call_site=call_site,
733
+ credential_postures=credential_postures,
734
+ )
735
+ try:
736
+ # No retry: authorize is not safely idempotent. A request that
737
+ # reached the cloud before the connection dropped would already have
738
+ # minted an ACT token and appended a ledger entry, so resending it
739
+ # risks a duplicate entry. Fail closed instead (as the Node SDK does).
740
+ response = self._client.post(_AUTHORIZE_PATH, json=payload)
741
+ except httpx.HTTPError as exc:
742
+ # Network failure → fail-closed: do not let the caller proceed.
743
+ raise ConsentUnreachableError(
744
+ _with_hint(_UNREACHABLE, self._origin_hint)
745
+ ) from exc
746
+ return _decision_from(response, self._origin_hint)
747
+
748
+ def close(self) -> None:
749
+ self._client.close()
750
+
751
+
752
+ class AsyncHttpConsentClient:
753
+ """:class:`AsyncConsentClient` backed by ``idside.eu`` (httpx, async).
754
+
755
+ The same conversation as :class:`HttpConsentClient` — same path, same
756
+ payload, same fail-closed reading of the answer, because they share the
757
+ functions that build and interpret both. What differs is the one hop that
758
+ has to: ``httpx.AsyncClient`` instead of ``httpx.Client``, so the wait for
759
+ the cloud yields the event loop instead of stopping it. On an ASGI
760
+ application that loop is also serving every other request in flight.
761
+ """
762
+
763
+ def __init__(
764
+ self,
765
+ base_url: str | None = None,
766
+ *,
767
+ api_key: str | None = None,
768
+ timeout: httpx.Timeout | float | None = None,
769
+ transport: httpx.AsyncBaseTransport | None = None,
770
+ ) -> None:
771
+ """Build the transport, resolving config from args then environment.
772
+
773
+ Identical resolution to the blocking client: explicit argument, else
774
+ ``IDSIDE_BASE_URL`` / ``IDSIDE_API_KEY``, else the defaults. An
775
+ empty-string env var is treated as absent. Every request carries a
776
+ ``User-Agent: idside-python/<ver>`` header. ``timeout`` overrides the
777
+ shared 5 s default (:data:`DEFAULT_TIMEOUT_SECONDS`).
778
+ """
779
+ resolved_base_url, headers, self._origin_hint = _resolved_config(
780
+ base_url, api_key
781
+ )
782
+ self._client = httpx.AsyncClient(
783
+ base_url=resolved_base_url,
784
+ timeout=timeout if timeout is not None else _DEFAULT_TIMEOUT,
785
+ transport=transport,
786
+ headers=headers,
787
+ )
788
+
789
+ async def authorize(
790
+ self,
791
+ *,
792
+ provider: str,
793
+ model: str,
794
+ request_hash: str,
795
+ user_id: str | None = None,
796
+ ubt: str | None = None,
797
+ call_site: str | None = None,
798
+ credential_postures: Sequence[str] = (),
799
+ ) -> ConsentDecision:
800
+ """Ask the cloud to authorize one AI request, without blocking the loop.
801
+
802
+ Every word of :meth:`HttpConsentClient.authorize` applies here: exactly
803
+ one of ``user_id`` or ``ubt`` names the end user, the UBT stays opaque to
804
+ the SDK, ``call_site`` names the endpoint so the cloud can decide per
805
+ endpoint, and ``credential_postures`` is sent on every request — empty
806
+ list included — because its presence is what tells the cloud this SDK
807
+ understands routing.
808
+ """
809
+ payload = _authorize_payload(
810
+ provider=provider,
811
+ model=model,
812
+ request_hash=request_hash,
813
+ user_id=user_id,
814
+ ubt=ubt,
815
+ call_site=call_site,
816
+ credential_postures=credential_postures,
817
+ )
818
+ try:
819
+ # No retry, for the same reason as the blocking client: a request
820
+ # that reached the cloud before the connection dropped has already
821
+ # minted an ACT token and appended a ledger entry.
822
+ response = await self._client.post(_AUTHORIZE_PATH, json=payload)
823
+ except httpx.HTTPError as exc:
824
+ # Network failure → fail-closed: do not let the caller proceed.
825
+ raise ConsentUnreachableError(
826
+ _with_hint(_UNREACHABLE, self._origin_hint)
827
+ ) from exc
828
+ return _decision_from(response, self._origin_hint)
829
+
830
+ async def aclose(self) -> None:
831
+ await self._client.aclose()