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/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
+ }