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/options.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Patents: https://sdk.idside.eu/patents
|
|
2
|
+
# This software may be covered by the patents and applications listed there.
|
|
3
|
+
|
|
4
|
+
"""The consent options an app can actually put to its users.
|
|
5
|
+
|
|
6
|
+
A developer wrapping OpenAI and Gemini needs to know what to render in their
|
|
7
|
+
consent UI, and the honest answer depends on which providers they wrap. This
|
|
8
|
+
module computes it from the shared catalogue, offline, with no network call.
|
|
9
|
+
|
|
10
|
+
The rule that shapes everything here:
|
|
11
|
+
|
|
12
|
+
A question reaches an end user only if at least one of the app's providers
|
|
13
|
+
can enforce it PER REQUEST.
|
|
14
|
+
|
|
15
|
+
An account-level control lives on the developer's API key and applies to all of
|
|
16
|
+
their users at once. Asking one person about it would manufacture the appearance
|
|
17
|
+
of a choice nobody has — two users answering differently would be treated
|
|
18
|
+
identically. Those become *disclosures* instead: facts about how this app is
|
|
19
|
+
configured, which the developer must be able to state and which the ledger
|
|
20
|
+
records, but which no consent dialog may present as a choice.
|
|
21
|
+
|
|
22
|
+
The exception is `routable`: an account-level control becomes a genuine per-user
|
|
23
|
+
one when the developer provisions several credentials with different privacy
|
|
24
|
+
postures, because the SDK can then pick the credential matching each answer.
|
|
25
|
+
That is the only route by which training opt-out can ever be honoured per user —
|
|
26
|
+
no provider exposes it as a request parameter.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from collections.abc import Mapping, Sequence
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from functools import cache, lru_cache
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Literal
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ConsentOption",
|
|
40
|
+
"Disclosure",
|
|
41
|
+
"catalogue",
|
|
42
|
+
"decidable_options",
|
|
43
|
+
"disclosures",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
_CATALOGUE_PATH = Path(__file__).resolve().parent / "consent-options.json"
|
|
47
|
+
_PROVIDER_DIR = Path(__file__).resolve().parent / "providers"
|
|
48
|
+
#: The only names that may ever become a path. See _raw_enforcement.
|
|
49
|
+
_KNOWN_PROVIDERS = frozenset(p.stem for p in _PROVIDER_DIR.glob("*.json"))
|
|
50
|
+
|
|
51
|
+
EnforcementLevel = Literal[
|
|
52
|
+
"enforced", "contractual", "account", "absent", "unsupported"
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
#: The levels at which no control exists, whatever the reason.
|
|
56
|
+
_NO_CONTROL = frozenset({"absent", "unsupported"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ConsentOption:
|
|
61
|
+
"""One question, ready to render."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
group: str
|
|
65
|
+
label_en: str
|
|
66
|
+
label_fr: str
|
|
67
|
+
description_en: str
|
|
68
|
+
description_fr: str
|
|
69
|
+
default: bool
|
|
70
|
+
legal_basis: str
|
|
71
|
+
gdpr: str
|
|
72
|
+
#: Which of the app's providers can honour it, and how.
|
|
73
|
+
enforced_by: tuple[str, ...] = ()
|
|
74
|
+
#: Providers that will honour it by *routing* the call to the credential
|
|
75
|
+
#: carrying that posture, rather than by a request parameter. The answer is
|
|
76
|
+
#: as real to the user either way; the mechanism differs, and a consent UI
|
|
77
|
+
#: that explains itself may want to say which.
|
|
78
|
+
routed_by: tuple[str, ...] = ()
|
|
79
|
+
|
|
80
|
+
def label(self, locale: str = "en") -> str:
|
|
81
|
+
return self.label_fr if locale.startswith("fr") else self.label_en
|
|
82
|
+
|
|
83
|
+
def description(self, locale: str = "en") -> str:
|
|
84
|
+
return self.description_fr if locale.startswith("fr") else self.description_en
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Disclosure:
|
|
89
|
+
"""Something the user cannot decide, and the developer must not hide.
|
|
90
|
+
|
|
91
|
+
``guaranteed`` — true for everyone already, and in the user's favour: the
|
|
92
|
+
terms of every provider that answers it rule the processing out
|
|
93
|
+
(``contractual`` and ``protective``). Terms that allow or impose it are no
|
|
94
|
+
guarantee, and neither is one provider among several whose terms do.
|
|
95
|
+
``beyond_user_control`` — the app's own configuration decides, or the
|
|
96
|
+
provider's terms do (``contractual`` terms that allow or impose it, or leave
|
|
97
|
+
it to the developer's organization): no answer in a dialog could change it.
|
|
98
|
+
If it is also ``routable``, the developer *could* give their users the choice
|
|
99
|
+
by provisioning a second credential; until they do, they are making it for
|
|
100
|
+
them.
|
|
101
|
+
``not_offered`` — no control for it is reachable through the endpoints the
|
|
102
|
+
descriptors wrap, whether the processing is ``absent`` from the API or
|
|
103
|
+
``unsupported`` there. Nobody decided anything; there is nothing to decide.
|
|
104
|
+
Saying "your app configured this" about a feature the API does not have
|
|
105
|
+
would be plainly false.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
option: ConsentOption
|
|
109
|
+
kind: Literal["guaranteed", "beyond_user_control", "not_offered"]
|
|
110
|
+
by_provider: dict[str, str]
|
|
111
|
+
routable_from: tuple[str, ...] = ()
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def routable(self) -> bool:
|
|
115
|
+
return bool(self.routable_from)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@lru_cache(maxsize=1)
|
|
119
|
+
def catalogue() -> dict[str, Any]:
|
|
120
|
+
"""The shared option catalogue, as shipped with this package."""
|
|
121
|
+
loaded: dict[str, Any] = json.loads(_CATALOGUE_PATH.read_text(encoding="utf-8"))
|
|
122
|
+
return loaded
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _option(
|
|
126
|
+
raw: dict[str, Any],
|
|
127
|
+
enforced_by: tuple[str, ...] = (),
|
|
128
|
+
routed_by: tuple[str, ...] = (),
|
|
129
|
+
) -> ConsentOption:
|
|
130
|
+
return ConsentOption(
|
|
131
|
+
name=raw["name"],
|
|
132
|
+
group=raw["group"],
|
|
133
|
+
label_en=raw["label_en"],
|
|
134
|
+
label_fr=raw["label_fr"],
|
|
135
|
+
description_en=raw["description_en"],
|
|
136
|
+
description_fr=raw["description_fr"],
|
|
137
|
+
default=raw["default"],
|
|
138
|
+
legal_basis=raw["legal_basis"],
|
|
139
|
+
gdpr=raw["gdpr"],
|
|
140
|
+
enforced_by=enforced_by,
|
|
141
|
+
routed_by=routed_by,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
#: A posture nobody can be routed away from is not a choice. One credential is
|
|
146
|
+
#: the app's single posture, whatever it is labelled; it takes a second for two
|
|
147
|
+
#: users to be treated differently.
|
|
148
|
+
_MIN_POSTURES_FOR_A_CHOICE = 2
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _routing_providers(routed: Mapping[str, Sequence[str]] | None) -> frozenset[str]:
|
|
152
|
+
"""The providers where the developer really can send two users two ways."""
|
|
153
|
+
if not routed:
|
|
154
|
+
return frozenset()
|
|
155
|
+
return frozenset(
|
|
156
|
+
provider
|
|
157
|
+
for provider, postures in routed.items()
|
|
158
|
+
if len(set(postures)) >= _MIN_POSTURES_FOR_A_CHOICE
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _enforcement(providers: list[str] | tuple[str, ...]) -> dict[str, dict[str, Any]]:
|
|
163
|
+
from idside.providers import load_descriptors
|
|
164
|
+
|
|
165
|
+
descriptors = load_descriptors()
|
|
166
|
+
known = {}
|
|
167
|
+
for name in providers:
|
|
168
|
+
descriptor = descriptors.get(name)
|
|
169
|
+
if descriptor is None:
|
|
170
|
+
continue
|
|
171
|
+
known[name] = getattr(descriptor, "enforcement", None) or _raw_enforcement(name)
|
|
172
|
+
return known
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@cache
|
|
176
|
+
def _raw_enforcement(provider: str) -> dict[str, Any]:
|
|
177
|
+
"""Read the enforcement map straight from the bundled descriptor.
|
|
178
|
+
|
|
179
|
+
The runtime ``ProviderDescriptor`` deliberately carries only what the SDK
|
|
180
|
+
needs to make a call. The enforcement map is documentation for humans and
|
|
181
|
+
input for a consent UI, so it is read here rather than widening that class.
|
|
182
|
+
|
|
183
|
+
The name is checked against the bundled descriptors before it ever reaches a
|
|
184
|
+
path. ``decidable_options`` is public API taking caller-supplied strings, and
|
|
185
|
+
interpolating one into a filesystem path made
|
|
186
|
+
``decidable_options(["../../../etc/passwd"])`` read and parse an arbitrary
|
|
187
|
+
file whose contents then flowed into the returned options.
|
|
188
|
+
"""
|
|
189
|
+
if provider not in _KNOWN_PROVIDERS:
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
path = _PROVIDER_DIR / f"{provider}.json"
|
|
193
|
+
if not path.exists():
|
|
194
|
+
return {}
|
|
195
|
+
raw: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
196
|
+
enforcement: dict[str, Any] = raw.get("enforcement", {})
|
|
197
|
+
return enforcement
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def decidable_options(
|
|
201
|
+
providers: list[str] | tuple[str, ...],
|
|
202
|
+
routed: Mapping[str, Sequence[str]] | None = None,
|
|
203
|
+
) -> list[ConsentOption]:
|
|
204
|
+
"""The questions this app may actually put to a user, in catalogue order.
|
|
205
|
+
|
|
206
|
+
Ordered by the catalogue rather than by provider, so the dialog never
|
|
207
|
+
depends on which client happened to be wrapped first, and each question is
|
|
208
|
+
asked once however many providers share it.
|
|
209
|
+
|
|
210
|
+
``routed`` maps a provider to the posture labels the app provisioned for it
|
|
211
|
+
— the keys of the ``credentials=`` mapping given to ``consent.wrap()``. An
|
|
212
|
+
account-level option marked ``routable`` in the descriptor becomes a real
|
|
213
|
+
question for that provider once there are **two** of them, because two users
|
|
214
|
+
answering differently can then be treated differently. With one, the app has
|
|
215
|
+
one posture and asking would manufacture a choice nobody has.
|
|
216
|
+
"""
|
|
217
|
+
levels = _enforcement(providers)
|
|
218
|
+
routing = _routing_providers(routed)
|
|
219
|
+
options = []
|
|
220
|
+
|
|
221
|
+
for raw in catalogue()["options"]:
|
|
222
|
+
enforced_by = tuple(
|
|
223
|
+
name
|
|
224
|
+
for name, enforcement in levels.items()
|
|
225
|
+
if enforcement.get(raw["name"], {}).get("level") == "enforced"
|
|
226
|
+
)
|
|
227
|
+
routed_by = tuple(
|
|
228
|
+
name
|
|
229
|
+
for name, enforcement in levels.items()
|
|
230
|
+
if name in routing
|
|
231
|
+
and name not in enforced_by
|
|
232
|
+
and enforcement.get(raw["name"], {}).get("routable")
|
|
233
|
+
)
|
|
234
|
+
if enforced_by or routed_by:
|
|
235
|
+
options.append(_option(raw, enforced_by, routed_by))
|
|
236
|
+
|
|
237
|
+
return options
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _kind_for(
|
|
241
|
+
entries: list[dict[str, Any]],
|
|
242
|
+
) -> Literal["guaranteed", "beyond_user_control", "not_offered"]:
|
|
243
|
+
"""Mirrors `kindFor` in the Node SDK — these values cross SDK boundaries.
|
|
244
|
+
|
|
245
|
+
``contractual`` alone says the terms decide, not which way: it used to be
|
|
246
|
+
read as a guarantee, which called Anthropic's review of flagged content and
|
|
247
|
+
OpenAI's "unless the org opts in" guaranteed to the user.
|
|
248
|
+
"""
|
|
249
|
+
if all(
|
|
250
|
+
entry["level"] == "contractual" and entry.get("protective") is True
|
|
251
|
+
for entry in entries
|
|
252
|
+
):
|
|
253
|
+
return "guaranteed"
|
|
254
|
+
if {entry["level"] for entry in entries} <= _NO_CONTROL:
|
|
255
|
+
return "not_offered"
|
|
256
|
+
return "beyond_user_control"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def disclosures(
|
|
260
|
+
providers: list[str] | tuple[str, ...],
|
|
261
|
+
routed: Mapping[str, Sequence[str]] | None = None,
|
|
262
|
+
) -> list[Disclosure]:
|
|
263
|
+
"""What the user cannot decide — stated, never asked.
|
|
264
|
+
|
|
265
|
+
``routed`` is the same argument :func:`decidable_options` takes, and moves
|
|
266
|
+
the same options: anything the app can now route on becomes a question, so
|
|
267
|
+
it stops being a disclosure. Passing it to one and not the other would say
|
|
268
|
+
two contradictory things about the same option.
|
|
269
|
+
"""
|
|
270
|
+
levels = _enforcement(providers)
|
|
271
|
+
asked = {option.name for option in decidable_options(providers, routed)}
|
|
272
|
+
found = []
|
|
273
|
+
|
|
274
|
+
for raw in catalogue()["options"]:
|
|
275
|
+
if raw["name"] in asked:
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
entries = {
|
|
279
|
+
name: enforcement[raw["name"]]
|
|
280
|
+
for name, enforcement in levels.items()
|
|
281
|
+
if enforcement.get(raw["name"], {}).get("level")
|
|
282
|
+
}
|
|
283
|
+
if not entries:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
found.append(
|
|
287
|
+
Disclosure(
|
|
288
|
+
option=_option(raw),
|
|
289
|
+
kind=_kind_for(list(entries.values())),
|
|
290
|
+
by_provider={name: entry["level"] for name, entry in entries.items()},
|
|
291
|
+
routable_from=tuple(
|
|
292
|
+
name for name, entry in entries.items() if entry.get("routable")
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return found
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Patents: https://sdk.idside.eu/patents
|
|
2
|
+
# This software may be covered by the patents and applications listed there.
|
|
3
|
+
|
|
4
|
+
"""Provider descriptors and their auto-loading from bundled JSON files.
|
|
5
|
+
|
|
6
|
+
A descriptor declares the *mechanics* of a provider (which calls to intercept,
|
|
7
|
+
where to inject forced params, which header carries the ACT token). The *policy*
|
|
8
|
+
(which params to force for a given consent state) is decided by the cloud and
|
|
9
|
+
returned as ``params_forced`` — it does not live here.
|
|
10
|
+
|
|
11
|
+
Add a standard provider by dropping a JSON file in this package; no Python needed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from importlib import resources
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
DEFAULT_CONSENT_HEADER = "X-AI-Consent-Token"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CallSite",
|
|
25
|
+
"CredentialRouting",
|
|
26
|
+
"ProviderDescriptor",
|
|
27
|
+
"ToolField",
|
|
28
|
+
"load_descriptors",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class CredentialRouting:
|
|
34
|
+
"""How this provider lets one app hold several privacy postures at once.
|
|
35
|
+
|
|
36
|
+
The privacy posture (does this traffic train the model, is it retained, in
|
|
37
|
+
which region) is not a request parameter anywhere — it is a property of the
|
|
38
|
+
credential. ``unit`` names what the credential selects: a *project* at
|
|
39
|
+
OpenAI and Gemini, a *workspace* at Anthropic, an *organisation* at Mistral.
|
|
40
|
+
|
|
41
|
+
``header`` is the per-request header that selects it, when the provider
|
|
42
|
+
validates one — only OpenAI does (``OpenAI-Project``). Without it, switching
|
|
43
|
+
posture means switching client, because a key is chosen at construction.
|
|
44
|
+
|
|
45
|
+
``header_value_pattern`` is checked before any value reaches an outgoing
|
|
46
|
+
request. It keeps CR/LF out of a header, and refuses a value of the wrong
|
|
47
|
+
kind: an OpenAI project id is ``proj_…`` where an API key is ``sk-…``, and
|
|
48
|
+
the developer who pastes the second must be stopped before the secret
|
|
49
|
+
leaves the process.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
header: str | None = None
|
|
53
|
+
header_value_pattern: str | None = None
|
|
54
|
+
unit: str = "credential"
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: dict[str, Any]) -> CredentialRouting:
|
|
58
|
+
return cls(
|
|
59
|
+
header=data.get("header"),
|
|
60
|
+
header_value_pattern=data.get("header_value_pattern"),
|
|
61
|
+
unit=data.get("unit") or "credential",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ToolField:
|
|
67
|
+
"""One argument through which a call can hand the model a tool.
|
|
68
|
+
|
|
69
|
+
A ``tools`` constraint is only as wide as what the SDK reads. Reading the
|
|
70
|
+
provider's ``tools`` key alone let ``functions``, ``web_search_options``, a
|
|
71
|
+
Mistral agent, a Gemini cache or a stored OpenAI conversation carry tools
|
|
72
|
+
straight past a refusal — so each call site names every such argument.
|
|
73
|
+
|
|
74
|
+
``kind`` decides how a refusal is applied:
|
|
75
|
+
|
|
76
|
+
- ``definitions`` — a list of tool definitions. An empty list carries none;
|
|
77
|
+
under ``strip`` the key is removed.
|
|
78
|
+
- ``enabler`` — any present, non-null value can bring tools with it: an
|
|
79
|
+
object switching a hosted tool on, or a reference to a server-side object
|
|
80
|
+
the SDK cannot see into. Removing it would change what the call means, so
|
|
81
|
+
it is refused under ``strip`` as under ``refuse``.
|
|
82
|
+
- ``input_items`` — an array whose items of the listed ``item_types`` carry
|
|
83
|
+
tool definitions. Refused under both, for the same reason.
|
|
84
|
+
|
|
85
|
+
``path`` is in the Python client's spelling; the descriptor's ``js_path``
|
|
86
|
+
belongs to the other SDK and is not read here.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
path: tuple[str, ...]
|
|
90
|
+
kind: str
|
|
91
|
+
item_types: frozenset[str] = frozenset()
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_dict(cls, data: dict[str, Any]) -> ToolField:
|
|
95
|
+
return cls(
|
|
96
|
+
path=tuple(data["path"]),
|
|
97
|
+
kind=data["kind"],
|
|
98
|
+
item_types=frozenset(data.get("item_types", ())),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class CallSite:
|
|
104
|
+
"""One endpoint to intercept, and what may be forced *on that endpoint*.
|
|
105
|
+
|
|
106
|
+
A provider's endpoints do not accept the same parameters. OpenAI's
|
|
107
|
+
``background`` exists on ``/v1/responses`` and nowhere else, so a
|
|
108
|
+
provider-wide allowlist could only ever be the intersection (losing the
|
|
109
|
+
lever) or the union (breaking ``chat.completions`` for exactly the users who
|
|
110
|
+
opted out). Hence one allowlist per site.
|
|
111
|
+
|
|
112
|
+
``id`` is the stable, language-neutral name sent to the cloud as
|
|
113
|
+
``call_site``. It is written in the Python client's spelling because the
|
|
114
|
+
descriptor is shared: the JS client may call the same endpoint something else
|
|
115
|
+
(``generateContent`` vs ``generate_content``), and one endpoint must have one
|
|
116
|
+
name on the wire.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
id: str
|
|
120
|
+
path: tuple[str, ...]
|
|
121
|
+
#: Extra method names on the SAME target object that reach the SAME HTTP
|
|
122
|
+
#: endpoint, and must therefore be enforced under this site's ``id`` —
|
|
123
|
+
#: ``chat.completions.parse`` posts to ``/v1/chat/completions`` just as
|
|
124
|
+
#: ``create`` does, and would otherwise reach the provider unenforced. Names
|
|
125
|
+
#: from every client language may be listed; whichever exists on the client
|
|
126
|
+
#: is patched, and an absent alias is skipped exactly like an absent call
|
|
127
|
+
#: site. Aliasing adds no new wire id: the cloud contract is unchanged.
|
|
128
|
+
aliases: tuple[str, ...] = ()
|
|
129
|
+
#: Keyword arguments whose presence means this call RESUMES a result that
|
|
130
|
+
#: already exists instead of creating one. Some client methods are
|
|
131
|
+
#: overloaded: ``responses.stream(response_id=…)`` replays a stored response
|
|
132
|
+
#: over ``GET /v1/responses/{id}``, where ``responses.stream(model=…,
|
|
133
|
+
#: input=…)`` creates one over POST. The resuming form carries no prompt and
|
|
134
|
+
#: no model, so it is let through unenforced — authorizing it would ask the
|
|
135
|
+
#: cloud to decide about an empty model and sign a token asserting an AI call
|
|
136
|
+
#: that never happened. It is not a bypass: a call let through carries no ACT
|
|
137
|
+
#: token, so the net refuses it the moment it turns out to carry a body.
|
|
138
|
+
resuming_params: tuple[str, ...] = ()
|
|
139
|
+
forceable_params: frozenset[str] = frozenset()
|
|
140
|
+
#: Every argument of THIS endpoint that can hand the model a tool, besides
|
|
141
|
+
#: the provider-wide ``tools_param``. See :class:`ToolField`.
|
|
142
|
+
tool_fields: tuple[ToolField, ...] = ()
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_dict(cls, data: dict[str, Any]) -> CallSite:
|
|
146
|
+
return cls(
|
|
147
|
+
id=data["id"],
|
|
148
|
+
path=tuple(data["path"]),
|
|
149
|
+
aliases=tuple(data.get("aliases", ())),
|
|
150
|
+
resuming_params=tuple(data.get("resuming_params", ())),
|
|
151
|
+
forceable_params=frozenset(data.get("forceable_params", ())),
|
|
152
|
+
tool_fields=tuple(
|
|
153
|
+
ToolField.from_dict(f) for f in data.get("tool_fields", ())
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class ProviderDescriptor:
|
|
160
|
+
"""Declarative description of a provider's injection mechanics.
|
|
161
|
+
|
|
162
|
+
``identity_param`` and ``ubt_param`` are the two exclusive kwargs a caller
|
|
163
|
+
may use to name the end user (developer-asserted id vs widget-issued User
|
|
164
|
+
Binding Token). Both are consumed by the SDK, never forwarded to the
|
|
165
|
+
provider, and excluded from the request hash. ``headers_kwarg`` names the
|
|
166
|
+
kwarg through which the provider's client accepts per-request HTTP headers
|
|
167
|
+
(also hash-excluded, since the ACT token travels there).
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
name: str
|
|
171
|
+
client_module: str
|
|
172
|
+
call_sites: tuple[CallSite, ...]
|
|
173
|
+
#: Hosts identifying THIS provider when several descriptors share a
|
|
174
|
+
#: ``client_module``. OpenAI-compatible providers are all reached through
|
|
175
|
+
#: the ``openai`` client, so the class name proves nothing — only the base
|
|
176
|
+
#: URL says who is on the other end. Empty means "the default for this
|
|
177
|
+
#: module".
|
|
178
|
+
client_base_url_hosts: frozenset[str] = frozenset()
|
|
179
|
+
identity_param: str = "user_id"
|
|
180
|
+
ubt_param: str = "ubt"
|
|
181
|
+
token_header: str = DEFAULT_CONSENT_HEADER
|
|
182
|
+
param_target: str = "kwargs" # "kwargs" | "headers"
|
|
183
|
+
headers_kwarg: str = "extra_headers"
|
|
184
|
+
protected_params: frozenset[str] = frozenset()
|
|
185
|
+
#: Path to the call argument carrying agentic tool definitions, e.g.
|
|
186
|
+
#: ``("tools",)`` or ``("config", "tools")`` for google-genai. Read — never
|
|
187
|
+
#: written — to honour a ``tools`` constraint from the cloud. ``None`` means
|
|
188
|
+
#: the SDK cannot see this provider's tools, and so cannot honour it.
|
|
189
|
+
tools_param: tuple[str, ...] | None = None
|
|
190
|
+
#: Headers a caller may never set on an enforced call, as declared: the ones
|
|
191
|
+
#: that choose which credential, account or project the call goes out on.
|
|
192
|
+
#: Compare through :meth:`is_reserved_header`, which is case-insensitive and
|
|
193
|
+
#: always reserves ``token_header`` as well.
|
|
194
|
+
reserved_headers: tuple[str, ...] = ()
|
|
195
|
+
#: How several credentials with different privacy postures can be held for
|
|
196
|
+
#: this provider, or ``None`` when it offers no such thing — in which case
|
|
197
|
+
#: ``consent.wrap(credentials=…)`` is refused rather than quietly ignored.
|
|
198
|
+
credential_routing: CredentialRouting | None = None
|
|
199
|
+
descriptor_version: str = "2"
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_dict(cls, data: dict[str, Any]) -> ProviderDescriptor:
|
|
203
|
+
return cls(
|
|
204
|
+
name=data["name"],
|
|
205
|
+
client_module=data["client_module"],
|
|
206
|
+
call_sites=tuple(CallSite.from_dict(s) for s in data["call_sites"]),
|
|
207
|
+
client_base_url_hosts=frozenset(data.get("client_base_url_hosts", ())),
|
|
208
|
+
identity_param=data.get("identity_param", "user_id"),
|
|
209
|
+
ubt_param=data.get("ubt_param", "ubt"),
|
|
210
|
+
token_header=data.get("token_header", DEFAULT_CONSENT_HEADER),
|
|
211
|
+
param_target=data.get("param_target", "kwargs"),
|
|
212
|
+
headers_kwarg=data.get("headers_kwarg", "extra_headers"),
|
|
213
|
+
protected_params=frozenset(data.get("protected_params", ())),
|
|
214
|
+
tools_param=(
|
|
215
|
+
tuple(data["tools_param"]) if data.get("tools_param") else None
|
|
216
|
+
),
|
|
217
|
+
reserved_headers=tuple(data.get("reserved_headers", ())),
|
|
218
|
+
credential_routing=(
|
|
219
|
+
CredentialRouting.from_dict(data["credential_routing"])
|
|
220
|
+
if data.get("credential_routing")
|
|
221
|
+
else None
|
|
222
|
+
),
|
|
223
|
+
descriptor_version=data.get("descriptor_version", "2"),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def is_reserved_header(self, name: str) -> bool:
|
|
227
|
+
"""Whether a caller-supplied header ``name`` must be refused.
|
|
228
|
+
|
|
229
|
+
Case-insensitive, because HTTP header names are, and because the
|
|
230
|
+
provider libraries merge header maps with case-sensitive dict keys:
|
|
231
|
+
``authorization`` next to ``Authorization`` is two headers on the wire,
|
|
232
|
+
and a list compared by exact spelling would miss the second.
|
|
233
|
+
"""
|
|
234
|
+
wanted = name.lower()
|
|
235
|
+
return wanted == self.token_header.lower() or any(
|
|
236
|
+
wanted == reserved.lower() for reserved in self.reserved_headers
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def call_site(self, site_id: str) -> CallSite | None:
|
|
240
|
+
"""Look one call site up by its wire id."""
|
|
241
|
+
return next((s for s in self.call_sites if s.id == site_id), None)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def load_descriptors() -> dict[str, ProviderDescriptor]:
|
|
245
|
+
"""Auto-discover and parse every ``*.json`` descriptor in this package."""
|
|
246
|
+
descriptors: dict[str, ProviderDescriptor] = {}
|
|
247
|
+
for entry in resources.files(__name__).iterdir():
|
|
248
|
+
if entry.name.endswith(".json"):
|
|
249
|
+
data = json.loads(entry.read_text(encoding="utf-8"))
|
|
250
|
+
descriptor = ProviderDescriptor.from_dict(data)
|
|
251
|
+
descriptors[descriptor.name] = descriptor
|
|
252
|
+
return descriptors
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"descriptor_version": "2",
|
|
3
|
+
"name": "anthropic",
|
|
4
|
+
"client_module": "anthropic",
|
|
5
|
+
"call_sites": [
|
|
6
|
+
{
|
|
7
|
+
"id": "messages.create",
|
|
8
|
+
"path": [
|
|
9
|
+
"messages",
|
|
10
|
+
"create"
|
|
11
|
+
],
|
|
12
|
+
"aliases": [
|
|
13
|
+
"parse",
|
|
14
|
+
"stream"
|
|
15
|
+
],
|
|
16
|
+
"forceable_params": [],
|
|
17
|
+
"tool_fields": [
|
|
18
|
+
{
|
|
19
|
+
"path": [
|
|
20
|
+
"mcp_servers"
|
|
21
|
+
],
|
|
22
|
+
"kind": "definitions",
|
|
23
|
+
"note": "Third-party MCP servers whose tools the model may call. Typed only on the beta endpoint; declared here so a client that accepts it on this one is read the same way."
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"note": "The only endpoint wrapped, so the per-site list and the provider union are the same today — both empty, see `forceable_params_note`. They are still written separately: the day `/v1/messages/batches` is wrapped, one of them will change and the other must not. Verified 2026-07-26 against `anthropic` 0.111.0: this signature also carries `cache_control`, `container`, `metadata` and `output_config` — none of them forceable, and the first two protected, see `protected_params`."
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"identity_param": "user_id",
|
|
30
|
+
"token_header": "X-AI-Consent-Token",
|
|
31
|
+
"param_target": "kwargs",
|
|
32
|
+
"protected_params": [
|
|
33
|
+
"model",
|
|
34
|
+
"messages",
|
|
35
|
+
"system",
|
|
36
|
+
"stream",
|
|
37
|
+
"cache_control",
|
|
38
|
+
"container"
|
|
39
|
+
],
|
|
40
|
+
"tools_param": [
|
|
41
|
+
"tools"
|
|
42
|
+
],
|
|
43
|
+
"reserved_headers": [
|
|
44
|
+
"X-Api-Key",
|
|
45
|
+
"Authorization",
|
|
46
|
+
"api-key"
|
|
47
|
+
],
|
|
48
|
+
"forceable_params": [],
|
|
49
|
+
"forceable_params_note": "EMPTY since 2026-07-27, and that is a statement rather than an omission. `inference_geo` is a real named parameter of `messages.create`, it lands in the request BODY, and it was verified live — but no option forces it any more: `data_residency` was downgraded to `account` because its two settings (`global`, `us`) do not answer \"inside my region\" for a European user, and we never learn anyone's region. An allowlist entry nothing forces is a door left open for no reason, so it is closed until the option comes back. History worth keeping: this descriptor injected `inference_geo` into HEADERS until 2026-07-26, where Anthropic does not read it — the call succeeded, the residency choice did not apply, and the public matrix showed it as enforced. `param_target` is `kwargs` now. The body schema stays strict (400 `Extra inputs are not permitted`), which is why this allowlist is checked against the real signature by tests/test_forceable_params.py.",
|
|
50
|
+
"enforcement": {
|
|
51
|
+
"training.foundation": {
|
|
52
|
+
"level": "contractual",
|
|
53
|
+
"protective": false,
|
|
54
|
+
"note": "No per-request parameter. The Commercial Terms say Anthropic may not train models on Customer Content, but the Service Specific Terms (section E, Development Partner Program) let a customer opt in by enabling the permissive data opt-in setting, after which Anthropic may use the data it submits, including to train models. The developer's organization decides, as with OpenAI's \"unless you explicitly opt in\", and no consent answer changes it; the ACT token is the proof the choice was collected and transmitted. Checked on 2026-10-12 against Anthropic's Commercial Terms and its Service Specific Terms (effective 2026-08-31)."
|
|
55
|
+
},
|
|
56
|
+
"training.personalisation": {
|
|
57
|
+
"level": "unsupported",
|
|
58
|
+
"note": "No parameter this SDK can send. The pinned client's BETA Messages signature carries a `user_profile_id`, whose semantics are not publicly documented as of 2026-07-26 — noted so this line is not read as 'the API has no such concept', which is no longer safe to claim. The beta endpoint is not wrapped, and the transport guard refuses it."
|
|
59
|
+
},
|
|
60
|
+
"training.analytics": {
|
|
61
|
+
"level": "contractual",
|
|
62
|
+
"protective": false,
|
|
63
|
+
"note": "Same terms, and the same exception: the Development Partner Program (Service Specific Terms, section E) lets the customer's organization opt the data it submits in, for Anthropic's products and services. Checked on 2026-10-12."
|
|
64
|
+
},
|
|
65
|
+
"memory": {
|
|
66
|
+
"level": "unsupported",
|
|
67
|
+
"note": "Not a parameter. Anthropic's `memory` feature reaches the API as a TOOL (`{\"type\": \"memory_...\"}` inside `tools`), so a user who refuses `agentic_tools` already loses it through the tool refusal. What is NOT possible is honouring a refusal of memory alone: the mechanism drops the whole `tools` argument, it cannot remove one entry from it. Declaring this enforced on the strength of the tool refusal would promise a granularity we do not have."
|
|
68
|
+
},
|
|
69
|
+
"agentic_tools": {
|
|
70
|
+
"level": "enforced",
|
|
71
|
+
"mechanism": "refuse_or_strip_tools",
|
|
72
|
+
"note": "ENFORCED by the SDK, not by a parameter. Every zero-retention arrangement excludes agentic features while the API itself does not block them: a call that hands the model tools falls outside the retention regime the user was promised, and the SDK is the only component that can see it happening, because it is the only one that sees the outgoing request. When the user has refused, the cloud returns a `tools` constraint and the SDK refuses the call (`ToolUseRefusedError`) or strips the tools, per the constraint. Refusing is the default: an agent stripped of its tools does not fail, it answers wrongly, and the developer has no way to know. Note what is NOT sent to us to make this work: nothing. The constraint follows from the consent state alone — we are never told whether a given call carried tools. Bounded by what the SDK reads: `tools_param` plus the call site's `tool_fields`, which name `mcp_servers` — a SIBLING of `tools` that hands execution to a third party and is excluded from zero retention just as tools are. A refusal read from `tools` alone did not see it, and `extra_body` carried it straight past; per-call options able to rewrite the body are now refused outright. The rule to carry forward: any parameter that delegates execution to a third party is a tool, whatever it is called."
|
|
73
|
+
},
|
|
74
|
+
"data_residency": {
|
|
75
|
+
"level": "account",
|
|
76
|
+
"routable": true,
|
|
77
|
+
"note": "Two different questions, and the answers differ — which is why this entry read as self-contradictory until it was spelled out.\n\nPER REQUEST: no. `inference_geo` is real, lands in the body, and was verified live; what does not exist is a VALUE to force. Its two settings are `global` (anywhere) and `us`, the option asks \"keep processing inside my region\", and neither answers that for a European user — who is the one this product is built for. Mapping the question to a value needs the user's region, which we never learn: `location` is `unsupported` at every provider, on purpose. Forcing `us` on a European who asked to stay in their region would be worse than forcing nothing.\n\nBY CREDENTIAL: yes, hence `routable: true`. `workspace_geo` is fixed when a workspace is created and `allowed_inference_geos` is a workspace setting, so a developer holding one workspace per posture lets the SDK send each call to the matching one. That path is the developer's to provision and it needs nobody to know anyone's region.\n\nSo the option stays out of the popup and stays offered to the developer as routable. Re-declaring it `enforced` would need a decision about what \"my region\" means when the provider offers two, and that is a product call rather than a missing line of code. Until 2026-07-27 it was declared `enforced`, the popup asked it, and no rule forced anything: a refusal that reached nothing, recorded in the ledger as though it had been honoured.\n\nVERIFIED by request: `global` (default) or `us`, rejected with 400 on models before Claude 4.6 (confirmed on sonnet-5, refused on haiku-4-5 and sonnet-4-5). The response reports `usage.inference_geo`, so what actually happened is verifiable. Billed x1.1 for `us`. Not available through the OpenAI-compatibility endpoint. This descriptor injected it into HEADERS until 2026-07-26, where Anthropic does not read it — the call succeeded and the residency choice did not apply. `param_target` is `kwargs` now."
|
|
78
|
+
},
|
|
79
|
+
"retention.conversation": {
|
|
80
|
+
"level": "contractual",
|
|
81
|
+
"protective": false,
|
|
82
|
+
"note": "No store parameter. Retention is governed by the arrangement; Claude Fable 5 and Mythos 5 are Covered Models with a mandatory 30-day retention that zero-retention cannot override."
|
|
83
|
+
},
|
|
84
|
+
"retention.cache": {
|
|
85
|
+
"level": "contractual",
|
|
86
|
+
"protective": false,
|
|
87
|
+
"note": "Prompt caching keeps KV tensors and cryptographic hashes in memory for the TTL. Structured outputs cache the JSON schema for up to 24h — never put personal data in property names, enums, const or pattern. The TTL IS a request parameter here — `cache_control: {type: 'ephemeral', ttl: '5m' | '1h'}` at the root of `messages.create` — but the lever points the other way round from OpenAI's: Anthropic's cache is opt-in, so without `cache_control` there is no cache at all, and FORCING it would create the retention this option exists to refuse. Honouring a refusal therefore means REMOVING an argument the developer passed, which is the tool-refusal mechanism and not `params_forced`; the SDK has no such removal for `cache_control` today, so this stays `contractual` rather than claiming one. `cache_control` is in `protected_params` meanwhile, so the cloud can never write it. Capping an accepted cache at `5m` instead of `1h` would be a genuine forcing, and is the piece worth building first."
|
|
88
|
+
},
|
|
89
|
+
"location": {
|
|
90
|
+
"level": "unsupported",
|
|
91
|
+
"note": "Not exposed as a request parameter."
|
|
92
|
+
},
|
|
93
|
+
"human_review": {
|
|
94
|
+
"level": "contractual",
|
|
95
|
+
"protective": false,
|
|
96
|
+
"note": "No request parameter. Anthropic says no personnel can read retained conversations by default and that human review happens only through a controlled access path — for example when its automated trust and safety systems flag content, which it may then keep up to 2 years whatever the arrangement, zero data retention included. On Covered Models its Service Specific Terms (section F) add that it may retain and perform safety reviews on inputs and outputs, superseding any modified retention commitment such as zero data retention. Nothing rules the review of flagged content out. Checked on 2026-10-12 against Anthropic's Service Specific Terms, its zero-data-retention guide and its privacy-centre articles on retention and Covered Models."
|
|
97
|
+
},
|
|
98
|
+
"retention.files": {
|
|
99
|
+
"level": "unsupported",
|
|
100
|
+
"note": "No per-request file expiry control on the endpoints this descriptor wraps."
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"enforcement_verified_at": "2026-07-26",
|
|
104
|
+
"credential_routing": {
|
|
105
|
+
"unit": "workspace",
|
|
106
|
+
"note": "API keys belong to a workspace, and `allowed_inference_geos` / retention are workspace settings. Routing means selecting the key, not a header."
|
|
107
|
+
}
|
|
108
|
+
}
|