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/_jcs.py ADDED
@@ -0,0 +1,130 @@
1
+ # Patents: https://sdk.idside.eu/patents
2
+ # This software may be covered by the patents and applications listed there.
3
+
4
+ """Minimal RFC 8785 (JCS) canonicalization — internal module.
5
+
6
+ The ``request_hash`` bound into ACT tokens is computed over the JCS canonical
7
+ form of the AI call payload, so every ID Side SDK (Python today, Node later)
8
+ produces bit-identical hashes for the same payload and any auditor can
9
+ recompute them. Implemented locally: the JSON subset the SDK needs is small
10
+ enough to keep in-tree, fully tested, and free of runtime dependencies.
11
+
12
+ Scope: JSON-compatible values only (``dict``/``list``/``str``/``int``/
13
+ ``float``/``bool``/``None``). Anything else must be normalized *before*
14
+ calling :func:`canonicalize` (see ``ProviderAdapter.extract_request_metadata``).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ from typing import Any
22
+
23
+ __all__ = ["canonicalize"]
24
+
25
+ #: Largest integer magnitude exactly representable as an IEEE-754 double (2^53).
26
+ #: Beyond it, JavaScript SDKs would parse a different value and hashes diverge.
27
+ _MAX_SAFE_INT = 2**53
28
+
29
+
30
+ def canonicalize(value: Any) -> bytes:
31
+ """Serialize ``value`` per RFC 8785 and return the canonical UTF-8 bytes.
32
+
33
+ Raises ``ValueError`` for anything outside the JCS-serializable domain:
34
+ non-JSON types, non-string dict keys, NaN/Infinity, integers beyond
35
+ IEEE-754 safe range, or strings containing lone surrogates.
36
+ """
37
+ out: list[str] = []
38
+ _write(value, out)
39
+ # UnicodeEncodeError (a ValueError subclass) surfaces lone surrogates here.
40
+ return "".join(out).encode("utf-8")
41
+
42
+
43
+ def _write(value: Any, out: list[str]) -> None:
44
+ if value is None:
45
+ out.append("null")
46
+ elif isinstance(value, bool):
47
+ out.append("true" if value else "false")
48
+ elif isinstance(value, str):
49
+ out.append(json.dumps(value, ensure_ascii=False))
50
+ elif isinstance(value, int):
51
+ out.append(_format_int(value))
52
+ elif isinstance(value, float):
53
+ out.append(_format_float(value))
54
+ elif isinstance(value, list):
55
+ out.append("[")
56
+ for i, item in enumerate(value):
57
+ if i:
58
+ out.append(",")
59
+ _write(item, out)
60
+ out.append("]")
61
+ elif isinstance(value, dict):
62
+ out.append("{")
63
+ for i, (key, item) in enumerate(_sorted_items(value)):
64
+ if i:
65
+ out.append(",")
66
+ out.append(json.dumps(key, ensure_ascii=False))
67
+ out.append(":")
68
+ _write(item, out)
69
+ out.append("}")
70
+ else:
71
+ raise ValueError(f"type '{type(value).__name__}' is not JSON-serializable")
72
+
73
+
74
+ def _sorted_items(value: dict[Any, Any]) -> list[tuple[str, Any]]:
75
+ for key in value:
76
+ if not isinstance(key, str):
77
+ raise ValueError(
78
+ f"object key {key!r} is not a string (JSON object keys must be)"
79
+ )
80
+ # RFC 8785 §3.2.3: keys sort by UTF-16 code units, not Unicode code points
81
+ # (an emoji, encoded as a surrogate pair, sorts before U+E000..U+FFFF).
82
+ return sorted(value.items(), key=lambda kv: kv[0].encode("utf-16-be"))
83
+
84
+
85
+ def _format_int(value: int) -> str:
86
+ if abs(value) > _MAX_SAFE_INT:
87
+ raise ValueError(
88
+ f"integer {value} exceeds the IEEE-754 safe range (±2^53); "
89
+ "it would not hash identically across SDK languages"
90
+ )
91
+ return str(value)
92
+
93
+
94
+ def _format_float(value: float) -> str:
95
+ """Format per ECMAScript ``Number::toString`` (RFC 8785 §3.2.2.3)."""
96
+ if math.isnan(value) or math.isinf(value):
97
+ raise ValueError("NaN and Infinity are not valid JSON numbers")
98
+ if value == 0.0:
99
+ return "0" # ECMAScript serializes both +0 and -0 as "0"
100
+ sign = ""
101
+ if value < 0:
102
+ sign = "-"
103
+ value = -value
104
+ # repr() yields the shortest digits that round-trip, same digits as ES6.
105
+ text = repr(value)
106
+ if "e" in text:
107
+ mantissa, _, exp_text = text.partition("e")
108
+ exp10 = int(exp_text)
109
+ else:
110
+ mantissa, exp10 = text, 0
111
+ int_part, _, frac_part = mantissa.partition(".")
112
+ digits_value = int(int_part + frac_part)
113
+ exponent = exp10 - len(frac_part)
114
+ while digits_value % 10 == 0:
115
+ digits_value //= 10
116
+ exponent += 1
117
+ digits = str(digits_value)
118
+ k = len(digits)
119
+ n = k + exponent # value == 0.<digits> * 10^n
120
+ if k <= n <= 21:
121
+ body = digits + "0" * (n - k)
122
+ elif 0 < n <= 21:
123
+ body = digits[:n] + "." + digits[n:]
124
+ elif -6 < n <= 0:
125
+ body = "0." + "0" * (-n) + digits
126
+ else:
127
+ head = digits[0] + ("." + digits[1:] if k > 1 else "")
128
+ exp_out = n - 1
129
+ body = f"{head}e{'+' if exp_out >= 0 else '-'}{abs(exp_out)}"
130
+ return sign + body
idside/_log.py ADDED
@@ -0,0 +1,163 @@
1
+ # Patents: https://sdk.idside.eu/patents
2
+ # This software may be covered by the patents and applications listed there.
3
+
4
+ """The journal: enough to debug a call that did not go out, and nothing more.
5
+
6
+ A fail-closed SDK with no journal is undebuggable at a customer site. When a
7
+ call does not leave the process, the developer needs to learn which call site
8
+ was recognised, which parameters a user's answer forced, how long the cloud
9
+ took, and whether it was reached at all. Every one of those is a fact about
10
+ *their own* request.
11
+
12
+ Three rules shape everything here, and the first two are refusals.
13
+
14
+ **Nothing secret is ever emitted.** Not the ID Side key, not the provider key,
15
+ not the end user's identifier in the clear, not a UBT, not a prompt, not an ACT
16
+ token, not a credential. Invariant #7 says a credential is a secret — never
17
+ logged, never in an error message — and the person on the other end deserves the
18
+ same treatment. What is emitted is either a fact about the developer's own
19
+ configuration (the provider, the call site, the model, the posture *label*) or a
20
+ value that is already a digest.
21
+
22
+ **Silent by default.** This module attaches a ``NullHandler`` and nothing else.
23
+ It never calls ``basicConfig``, never touches the root logger, never writes to a
24
+ stream. Without that handler, Python's ``lastResort`` prints WARNING and above
25
+ to stderr, so "we only log at DEBUG" would not have been an answer on its own: a
26
+ library that prints uninvited is a library that gets removed.
27
+
28
+ **Observation is not influence** (invariant #8). A logging handler is arbitrary
29
+ application code running in the middle of the enforcement path, and formatters
30
+ routinely walk and normalise what they are handed. So a record is built from
31
+ scalars and fresh copies, never from the live objects the SDK is about to act
32
+ on — no ``ConsentDecision``, no kwargs dict, no route. A handler that mutates
33
+ everything it is shown changes nothing about the call that follows, and a test
34
+ pins exactly that. There is no hook, no callback and no ``on_event=`` here
35
+ either: a point where an application could watch *and* answer would be the
36
+ opaque tunnel through the back door.
37
+
38
+ The net (``_guard.py``) uses none of this. It stays blind by design, and
39
+ observability is not a reason to give it a first thing to record.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import hashlib
45
+ import logging
46
+ import os
47
+ import time
48
+ from typing import Any
49
+
50
+ __all__ = ["event", "logger", "pseudonym", "timer"]
51
+
52
+ _LOGGER_NAME = "idside"
53
+
54
+ #: Per-process salt for the subject pseudonym. Random, never persisted, never
55
+ #: emitted. A ``user_id`` is low-entropy on purpose — ``usr_marie_456`` is a
56
+ #: dictionary away — so an unsalted digest of one is not a pseudonym but an
57
+ #: encoding: anyone holding the log hashes their guesses and reads the names
58
+ #: back. Salting per process keeps the only property debugging actually needs,
59
+ #: correlating one user's calls *within one log*, and drops the one nobody
60
+ #: asked for, recognising that user across logs, machines or restarts.
61
+ _SALT = os.urandom(32)
62
+
63
+ #: Enough digest to tell users apart in a log, far too little to attack.
64
+ _PSEUDONYM_CHARS = 12
65
+
66
+ #: The request hash is already a digest and is what the ledger indexes, so it is
67
+ #: the honest answer to "which proof was minted for this call?" — the ACT token
68
+ #: itself is bearer material and is never emitted. Truncated anyway: a full hash
69
+ #: of a short prompt is a dictionary attack away from the prompt.
70
+ _HASH_CHARS = 12
71
+
72
+
73
+ def logger() -> logging.Logger:
74
+ """The package logger. The application owns its handlers, level and format."""
75
+ return logging.getLogger(_LOGGER_NAME)
76
+
77
+
78
+ def pseudonym(value: object) -> str:
79
+ """A stable, unforgeable stand-in for one end user, for this process only.
80
+
81
+ Accepts a ``user_id`` or a UBT and treats both the same way: what comes back
82
+ names nobody, and the value that went in cannot be recovered from it or
83
+ guessed into it.
84
+ """
85
+ digest = hashlib.sha256(_SALT + str(value).encode("utf-8")).hexdigest()
86
+ return digest[:_PSEUDONYM_CHARS]
87
+
88
+
89
+ def short_hash(request_hash: object) -> str:
90
+ """The correlating half of a ``sha256:<hex>`` request hash."""
91
+ text = str(request_hash)
92
+ _, _, hexpart = text.rpartition(":")
93
+ return hexpart[:_HASH_CHARS]
94
+
95
+
96
+ class timer:
97
+ """Milliseconds spent inside the block, for the one hop worth measuring.
98
+
99
+ Monotonic: a journal that reports a negative authorize because NTP stepped
100
+ the clock is a journal that gets distrusted for the one number it exists to
101
+ report.
102
+ """
103
+
104
+ __slots__ = ("_started", "ms")
105
+
106
+ def __init__(self) -> None:
107
+ self.ms = 0
108
+
109
+ def __enter__(self) -> timer:
110
+ self._started = time.monotonic_ns()
111
+ return self
112
+
113
+ def __exit__(self, *_exc: object) -> None:
114
+ self.ms = (time.monotonic_ns() - self._started) // 1_000_000
115
+
116
+
117
+ def event(name: str, **fields: Any) -> None:
118
+ """Emit one event at DEBUG, as a stable text line and a structured payload.
119
+
120
+ Both, because applications read logs both ways: a terminal reads the
121
+ message, an aggregator reads ``record.idside``. A field present in one and
122
+ absent from the other would be a field somebody debugs without.
123
+
124
+ Field order is the caller's, and stable, so two runs of the same path
125
+ produce comparable lines — which is what lets the cross-SDK parity script
126
+ diff Python's journal against Node's. ``None`` values are dropped rather
127
+ than rendered: "the cloud named no posture" is said by the absence of
128
+ ``posture=``, not by ``posture=None``.
129
+
130
+ Callers pass scalars and copies. Nothing here reaches into a live object,
131
+ and nothing a handler does to what it receives can travel back.
132
+ """
133
+ log = logger()
134
+ if not log.isEnabledFor(logging.DEBUG):
135
+ return # do not pay for formatting nobody will read
136
+ payload = {key: value for key, value in fields.items() if value is not None}
137
+ rendered = " ".join(f"{key}={_render(value)}" for key, value in payload.items())
138
+ log.debug(
139
+ f"{name} {rendered}" if rendered else name,
140
+ extra={"idside": {"event": name, **payload}},
141
+ )
142
+
143
+
144
+ def _render(value: Any) -> str:
145
+ """One value, in a form that survives a grep and never spans a line.
146
+
147
+ A list becomes ``a+b`` rather than ``['a', 'b']``: the parameters a consent
148
+ state forced are read far more often than they are parsed, and the sorted
149
+ join is identical in both SDKs, which is what the parity script compares.
150
+ """
151
+ if isinstance(value, bool):
152
+ return "true" if value else "false"
153
+ if isinstance(value, (list, tuple)):
154
+ return (
155
+ "+".join(str(item) for item in sorted(str(item) for item in value)) or "-"
156
+ )
157
+ return str(value).replace(" ", "_")
158
+
159
+
160
+ # Silent by default, and this line is what makes it true: with no handler at
161
+ # all, Python's `lastResort` prints WARNING and above straight to stderr. The
162
+ # application adds its own handler if it wants to read any of this.
163
+ logging.getLogger(_LOGGER_NAME).addHandler(logging.NullHandler())
idside/_routing.py ADDED
@@ -0,0 +1,280 @@
1
+ # Patents: https://sdk.idside.eu/patents
2
+ # This software may be covered by the patents and applications listed there.
3
+
4
+ """Credential routing: which of the developer's credentials carries this call.
5
+
6
+ No provider lets a developer switch training off per request. The setting lives
7
+ on the credential — per project at OpenAI and Gemini, per workspace at
8
+ Anthropic, per organisation at Mistral — so one key means one posture for every
9
+ user of the app at once. A developer who provisions one credential per posture
10
+ lets the SDK send each call on the one matching that user's answer, and the
11
+ account control becomes a personal one. It is the only route by which a
12
+ training opt-out can be honoured per user today.
13
+
14
+ What this module does and does not decide:
15
+
16
+ - it does **not** decide which posture a consent state calls for. That is
17
+ policy, it lives in the cloud, and the SDK is told the answer
18
+ (``credential_posture`` on the authorize response). There is no
19
+ consent→credential table here, exactly as there is no consent→params one;
20
+ - it does **not** let the developer choose either. They label what they hold;
21
+ the label is matched against the cloud's answer. A callback would hand them
22
+ the user's preferences, which is invariant #1;
23
+ - it fails closed. A posture nobody provisioned stops the call, because the
24
+ alternative is sending it on a credential the user's answer excludes, with
25
+ nothing anywhere to say so.
26
+
27
+ This module is internal (``_routing``). The public surface is the
28
+ ``credentials=`` argument of :func:`idside.consent.wrap`.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from collections.abc import Callable, Mapping, Sequence
35
+ from dataclasses import dataclass, field
36
+ from typing import Any
37
+
38
+ from idside.exceptions import CredentialRoutingError, MissingCredentialError
39
+ from idside.providers import CredentialRouting, ProviderDescriptor
40
+
41
+ #: Fallback shape for a routed header value when the descriptor pins no pattern:
42
+ #: printable ASCII, no space, no control character. Its job is narrow but not
43
+ #: optional — the value is written into an outgoing HTTP header, and a CR or LF
44
+ #: in it is header injection on the developer's own provider request.
45
+ _SAFE_HEADER_VALUE = re.compile(r"[!-~]{1,200}")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Route:
50
+ """Where one call must go once the cloud has named a posture.
51
+
52
+ Exactly one of the two mechanisms applies, and which one is a property of
53
+ the provider, not of the call: ``headers`` for a provider that validates a
54
+ per-request credential header (OpenAI's ``OpenAI-Project``), ``call`` for
55
+ everyone else, where the key is fixed at client construction and switching
56
+ posture means switching client. A route with neither is the ordinary case:
57
+ the cloud named no posture, and nothing about the call changes.
58
+ """
59
+
60
+ headers: dict[str, str] = field(default_factory=dict)
61
+ call: Callable[..., Any] | None = None
62
+
63
+
64
+ #: The unrouted call: no header to add, and the client the developer wrapped.
65
+ NO_ROUTE = Route()
66
+
67
+
68
+ def _resolve(client: Any, path: Sequence[str]) -> Any:
69
+ """Walk an attribute path on a client, or return None if it does not exist."""
70
+ current = client
71
+ for attr in path:
72
+ if not hasattr(current, attr):
73
+ return None
74
+ current = getattr(current, attr)
75
+ return current
76
+
77
+
78
+ class CredentialRouter:
79
+ """The developer's labelled credentials, resolved once at ``wrap()`` time.
80
+
81
+ Everything that can be checked early is checked here rather than on the AI
82
+ request path: an unroutable provider, a bare identifier where only a client
83
+ will do, an identifier of the wrong shape, an alternative client missing the
84
+ endpoint being wrapped. A developer meets those at startup, not in
85
+ production, and never as a call that quietly went out on the wrong account.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ descriptor: ProviderDescriptor,
91
+ credentials: Mapping[str, Any],
92
+ call_sites: Sequence[tuple[str, Sequence[str]]],
93
+ *,
94
+ is_enforced: Callable[[Any], bool] = lambda method: False,
95
+ ) -> None:
96
+ """Validate and resolve ``credentials`` against a provider and a client.
97
+
98
+ ``call_sites`` are the endpoints ``wrap()`` is about to patch, as
99
+ ``(id, path)`` pairs — one per *method*, so an aliased endpoint appears
100
+ once per alias with its own path. An alternative client must expose
101
+ every one of them, or a call routed to it would find nothing to call,
102
+ or worse, the wrong thing: two methods may share a wire id and still
103
+ behave differently (``messages.parse`` is not ``messages.create``).
104
+
105
+ ``is_enforced`` recognises a method this SDK has already patched. It is
106
+ injected so the enforcement marker stays private to
107
+ :mod:`idside.consent`; routing to an already-wrapped client would
108
+ authorize twice and stack two ACT tokens onto one request.
109
+ """
110
+ if not isinstance(credentials, Mapping):
111
+ raise CredentialRoutingError(
112
+ "consent.wrap(credentials=…) takes a mapping of posture label → "
113
+ f"credential, not {type(credentials).__name__}. The label is what "
114
+ "the ID Side cloud names when it routes a call; a bare sequence "
115
+ "of credentials would carry no posture at all."
116
+ )
117
+ routing = descriptor.credential_routing
118
+ if routing is None:
119
+ raise CredentialRoutingError(
120
+ f"consent.wrap() was given credentials for '{descriptor.name}', "
121
+ "which declares no credential routing: it exposes no account-level "
122
+ "privacy posture a second credential could carry, so routing "
123
+ "between two of them would change nothing for the user. Remove the "
124
+ "credentials argument."
125
+ )
126
+
127
+ self._descriptor = descriptor
128
+ self._routing = routing
129
+ self._headers: dict[str, dict[str, str]] = {}
130
+ self._calls: dict[tuple[str, tuple[str, ...]], Callable[..., Any]] = {}
131
+
132
+ for posture, credential in credentials.items():
133
+ if not isinstance(posture, str) or not posture.strip():
134
+ raise CredentialRoutingError(
135
+ f"Every credential passed to consent.wrap() must be labelled "
136
+ f"with the posture it carries, and {posture!r} is not a usable "
137
+ "label. The label is what the ID Side cloud names when it "
138
+ "routes a call, so it must match the one configured for this "
139
+ "app in the dashboard."
140
+ )
141
+ if isinstance(credential, str):
142
+ self._headers[posture] = {
143
+ self._header_name(posture): self._checked_value(credential, posture)
144
+ }
145
+ else:
146
+ self._add_client(posture, credential, call_sites, is_enforced)
147
+
148
+ #: Sorted so two processes holding the same configuration send the cloud
149
+ #: the same payload.
150
+ self.postures: tuple[str, ...] = tuple(sorted(credentials))
151
+
152
+ # ── configuration ──────────────────────────────────────────────────────
153
+
154
+ def _header_name(self, posture: str) -> str:
155
+ """The routing header, or explain why a string cannot work here."""
156
+ if self._routing.header:
157
+ return self._routing.header
158
+ raise CredentialRoutingError(
159
+ f"The credential labelled '{posture}' is a bare identifier, but "
160
+ f"'{self._descriptor.name}' reads no per-request header naming a "
161
+ f"{self._routing.unit}: its key is chosen when the client is built. "
162
+ f"Pass a second client constructed with that {self._routing.unit}'s "
163
+ "key instead — a string here would be accepted and never sent, which "
164
+ "is the one outcome routing exists to prevent."
165
+ )
166
+
167
+ def _checked_value(self, value: str, posture: str) -> str:
168
+ """Validate a routed header value; never echo it back on failure."""
169
+ pattern = self._routing.header_value_pattern
170
+ ok = (
171
+ re.fullmatch(pattern, value)
172
+ if pattern
173
+ else _SAFE_HEADER_VALUE.fullmatch(value)
174
+ )
175
+ if ok:
176
+ return value
177
+ expected = f" (expected {pattern})" if pattern else ""
178
+ raise CredentialRoutingError(
179
+ f"The credential labelled '{posture}' is not a "
180
+ f"{self._descriptor.name} {self._routing.unit} identifier{expected}. "
181
+ "The value is not repeated here on purpose: if what you pasted is an "
182
+ "API key, it is a secret, and it was about to travel in a header that "
183
+ "is not the one that carries it."
184
+ )
185
+
186
+ def _add_client(
187
+ self,
188
+ posture: str,
189
+ credential: Any,
190
+ call_sites: Sequence[tuple[str, Sequence[str]]],
191
+ is_enforced: Callable[[Any], bool],
192
+ ) -> None:
193
+ """Resolve every wrapped endpoint on an alternative provider client."""
194
+ self._check_same_provider(posture, credential)
195
+ for _site_id, path in call_sites:
196
+ method = _resolve(credential, path)
197
+ if not callable(method):
198
+ raise CredentialRoutingError(
199
+ f"The client passed as credential '{posture}' does not expose "
200
+ f"'{'.'.join(path)}', which consent.wrap() is enforcing on the "
201
+ "client you wrapped. A call routed to it would have nothing to "
202
+ "call. Pass a client of the same kind, built with that "
203
+ f"{self._routing.unit}'s key."
204
+ )
205
+ if is_enforced(method):
206
+ raise CredentialRoutingError(
207
+ f"The client passed as credential '{posture}' is already "
208
+ "wrapped by consent.wrap(). Routing a call to it would "
209
+ "authorize the same request twice and stack two ACT tokens on "
210
+ "it. Pass the unwrapped client."
211
+ )
212
+ self._calls[(posture, tuple(path))] = method
213
+
214
+ def _check_same_provider(self, posture: str, credential: Any) -> None:
215
+ """Refuse a routed client that belongs to a different provider.
216
+
217
+ Best effort, and it only ever refuses on a positive identification. It
218
+ matters because the shapes coincide: an ``OpenAI`` client pointed at
219
+ another vendor's base URL is still an ``OpenAI`` client, so every call
220
+ site resolves and the only thing that differs is which company receives
221
+ the prompt.
222
+ """
223
+ from idside.adapters import get_adapter_for
224
+ from idside.exceptions import UnsupportedProviderError
225
+
226
+ try:
227
+ detected = get_adapter_for(credential).name
228
+ except UnsupportedProviderError:
229
+ return # unidentifiable is not evidence of anything
230
+ if detected != self._descriptor.name:
231
+ raise CredentialRoutingError(
232
+ f"The client passed as credential '{posture}' is a "
233
+ f"'{detected}' client, but consent.wrap() is enforcing "
234
+ f"'{self._descriptor.name}'. Routing between two providers would "
235
+ "send the request to a company the user's answer never mentioned."
236
+ )
237
+
238
+ # ── the request path ───────────────────────────────────────────────────
239
+
240
+ def route(self, posture: str, call_path: Sequence[str]) -> Route:
241
+ """The route for ``posture``, or refuse the call.
242
+
243
+ Called once per AI request, after the cloud has named the posture this
244
+ user's answer requires. ``call_path`` is the attribute path of the method
245
+ being called, not the endpoint's wire id: an aliased method must land on
246
+ the same method of the alternative client.
247
+ """
248
+ headers = self._headers.get(posture)
249
+ if headers is not None:
250
+ return Route(headers=headers)
251
+ call = self._calls.get((posture, tuple(call_path)))
252
+ if call is not None:
253
+ return Route(call=call)
254
+ raise MissingCredentialError(
255
+ missing_message(self._descriptor, posture, self.postures),
256
+ posture=posture,
257
+ provider=self._descriptor.name,
258
+ provisioned=self.postures,
259
+ )
260
+
261
+
262
+ def missing_message(
263
+ descriptor: ProviderDescriptor, posture: str, provisioned: tuple[str, ...]
264
+ ) -> str:
265
+ """Say what is missing, in the provider's own vocabulary.
266
+
267
+ Names the labels that do exist and never a credential: the labels are the
268
+ developer's own words for their configuration, the credentials are secrets.
269
+ """
270
+ routing: CredentialRouting | None = descriptor.credential_routing
271
+ unit = routing.unit if routing else "credential"
272
+ have = ", ".join(f"'{name}'" for name in provisioned) if provisioned else "none"
273
+ return (
274
+ f"This user's consent requires the '{posture}' credential for "
275
+ f"'{descriptor.name}', and consent.wrap() was given none carrying it "
276
+ f"(provisioned: {have}). The AI call was not made: sending it on another "
277
+ f"{unit} would place this user under a privacy regime they declined, and "
278
+ f"nothing would record that it happened. Provision a {unit} with that "
279
+ f"posture and pass it to consent.wrap() as credentials={{'{posture}': …}}."
280
+ )