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.
@@ -0,0 +1,462 @@
1
+ # Patents: https://sdk.idside.eu/patents
2
+ # This software may be covered by the patents and applications listed there.
3
+
4
+ """Descriptor-driven provider adapter — the injection engine.
5
+
6
+ The adapter handles only the *mechanics* (which call to intercept, where to inject
7
+ the cloud-decided params, which header carries the ACT token). The *policy* (which
8
+ params to force) is decided by the cloud and passed in as ``params_forced``.
9
+
10
+ Subclass and override for providers whose API does not fit the declarative model.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import re
17
+ from collections.abc import Mapping
18
+ from typing import Any, NamedTuple
19
+
20
+ from idside import _jcs
21
+ from idside.adapters._call_options import (
22
+ is_not_given,
23
+ is_sentinel,
24
+ mapping_view,
25
+ transport_options,
26
+ )
27
+ from idside.exceptions import (
28
+ ConsentTokenError,
29
+ ToolUseRefusedError,
30
+ UnsupportedProviderError,
31
+ )
32
+ from idside.providers import CallSite, ProviderDescriptor, ToolField
33
+
34
+ _JSON_SCALARS = (bool, str, int, float)
35
+
36
+
37
+ #: A plain-dict view of a value, or None if it is not one. Tolerates Pydantic
38
+ #: models because google-genai's ``config`` may be a ``GenerateContentConfig``
39
+ #: rather than a dict, and the tools we are looking for sit inside it either way.
40
+ #: The same reading the per-call option checks make, so the two cannot drift.
41
+ _as_mapping = mapping_view
42
+
43
+
44
+ def _spellings(key: str) -> tuple[str, ...]:
45
+ """A key as written, and its camelCase twin.
46
+
47
+ google-genai validates ``config`` with Pydantic aliases, so
48
+ ``{"cachedContent": …}`` is the same argument as ``{"cached_content": …}``.
49
+ A path read in one spelling only is a tool the other one walks past.
50
+ """
51
+ camel = re.sub(r"_([a-z])", lambda match: match.group(1).upper(), key)
52
+ return (key,) if camel == key else (key, camel)
53
+
54
+
55
+ def _read_paths(value: Any, path: tuple[str, ...]) -> list[Any]:
56
+ """Every value found at ``path``, in either spelling at each step."""
57
+ if not path:
58
+ return [value]
59
+ mapping = _as_mapping(value)
60
+ if mapping is None:
61
+ return []
62
+ head, *rest = path
63
+ return [
64
+ found
65
+ for key in _spellings(head)
66
+ if key in mapping
67
+ for found in _read_paths(mapping[key], tuple(rest))
68
+ ]
69
+
70
+
71
+ def _drop_path(kwargs: dict[str, Any], path: tuple[str, ...]) -> dict[str, Any]:
72
+ """Return a copy of ``kwargs`` without the argument at ``path``.
73
+
74
+ Removes the key rather than emptying it: several providers reject an empty
75
+ ``tools`` list, and "no tools" is what the user asked for, not "zero tools".
76
+ Both spellings go, so a camelCase twin cannot carry the tools on. Never
77
+ mutates the caller's objects — a wrapped client must not rewrite the dict
78
+ the application still holds a reference to.
79
+ """
80
+ head, *rest = path
81
+ mapping = _as_mapping(kwargs)
82
+ keys = [key for key in _spellings(head) if mapping is not None and key in mapping]
83
+ if mapping is None or not keys:
84
+ return kwargs
85
+
86
+ new = dict(mapping)
87
+ for key in keys:
88
+ if not rest:
89
+ del new[key]
90
+ else:
91
+ nested = _as_mapping(new[key])
92
+ new[key] = new[key] if nested is None else _drop_path(nested, tuple(rest))
93
+ return new
94
+
95
+
96
+ def _carries_tools(kwargs: dict[str, Any], field: ToolField) -> bool:
97
+ """Whether this call hands the model a tool through ``field``.
98
+
99
+ Presence, per kind: ``definitions`` carry tools unless they are an empty
100
+ list or an empty mapping — ``tools=[]`` is no tools, in both SDKs — and any
101
+ other value counts, ``False``, ``0`` and ``""`` included: a value nobody
102
+ expected there is no proof that no tool is. An ``enabler`` carries them
103
+ as soon as it is given at all, an empty object included, since
104
+ ``web_search_options={}`` is exactly how hosted search is switched on. An
105
+ ``input_items`` array carries them when one of its items is of a listed
106
+ type; a string input has no items. A library's "not given" sentinel is
107
+ absence under every kind. A kind this SDK does not know yet is read as an
108
+ enabler: a tool field nobody taught it to read is not one it may assume
109
+ empty.
110
+ """
111
+ for value in _read_paths(kwargs, field.path):
112
+ if is_not_given(value):
113
+ continue
114
+ if field.kind == "definitions":
115
+ if not (isinstance(value, (list, tuple, Mapping)) and len(value) == 0):
116
+ return True
117
+ elif field.kind == "input_items":
118
+ if isinstance(value, (list, tuple)) and any(
119
+ _item_type(item) in field.item_types for item in value
120
+ ):
121
+ return True
122
+ else:
123
+ return True
124
+ return False
125
+
126
+
127
+ def _item_type(item: Any) -> Any:
128
+ """The ``type`` of an input item, whether a dict or a typed model.
129
+
130
+ An item that names no type (absent or null) but an ``id`` is a reference to
131
+ a stored item: OpenAI's ``ItemReference`` makes ``type`` optional, so
132
+ ``{"id": "fc_…"}`` alone is an ``item_reference``. Read by its declared type
133
+ only, it walked past a refusal naming ``item_reference``.
134
+ """
135
+ if isinstance(item, dict):
136
+ kind, reference = item.get("type"), item.get("id")
137
+ else:
138
+ kind, reference = getattr(item, "type", None), getattr(item, "id", None)
139
+ if kind is None and reference is not None:
140
+ return "item_reference"
141
+ return kind
142
+
143
+
144
+ class ResolvedCallSite(NamedTuple):
145
+ """A descriptor call site, located on a real client instance.
146
+
147
+ ``target``/``method`` are what gets patched; ``site`` is what it *is* — the
148
+ endpoint's stable id and the parameters that endpoint accepts.
149
+ """
150
+
151
+ target: Any
152
+ method: str
153
+ site: CallSite
154
+
155
+
156
+ def _merge_headers(current: Any, injected: dict[str, Any]) -> dict[str, Any]:
157
+ """Replace injected HTTP fields case-insensitively in a fresh header map."""
158
+ headers = dict(current or {})
159
+ replaced = {name.lower() for name in injected}
160
+ return {
161
+ **{
162
+ name: value
163
+ for name, value in headers.items()
164
+ if not isinstance(name, str) or name.lower() not in replaced
165
+ },
166
+ **injected,
167
+ }
168
+
169
+
170
+ def _normalize(value: Any, path: str) -> Any:
171
+ """Return a JSON-compatible copy of ``value``, or raise ``ConsentTokenError``.
172
+
173
+ Strict by design: dict/list/str/int/float/bool/None pass through, objects
174
+ exposing ``model_dump()`` (Pydantic v2) are converted recursively, anything
175
+ else is an error. A silent fallback (``str()``/``repr()``) would embed
176
+ interpreter-dependent text — at worst a memory address — into the request
177
+ hash and destroy its reproducibility.
178
+ """
179
+ if value is None or isinstance(value, _JSON_SCALARS):
180
+ return value
181
+ if isinstance(value, (list, tuple)):
182
+ return [_normalize(item, f"{path}[{i}]") for i, item in enumerate(value)]
183
+ if isinstance(value, dict):
184
+ normalized = {}
185
+ for key, item in value.items():
186
+ if not isinstance(key, str):
187
+ raise ConsentTokenError(
188
+ f"Cannot compute a deterministic request_hash: object key "
189
+ f"{key!r} at '{path}' is not a string. JSON object keys "
190
+ "must be strings."
191
+ )
192
+ normalized[key] = _normalize(item, f"{path}.{key}")
193
+ return normalized
194
+ if isinstance(value, type):
195
+ # A CLASS, not an instance. `chat.completions.parse(response_format=…)`
196
+ # takes the model class itself, and the provider puts its JSON schema on
197
+ # the wire — so the schema is what the request hash must cover. Checked
198
+ # before `model_dump`, which on a class is an unbound function that is
199
+ # callable and blows up when called.
200
+ json_schema = getattr(value, "model_json_schema", None)
201
+ if callable(json_schema):
202
+ return _normalize(json_schema(), path)
203
+ else:
204
+ model_dump = getattr(value, "model_dump", None)
205
+ if callable(model_dump):
206
+ return _normalize(model_dump(), path)
207
+ raise ConsentTokenError(
208
+ f"Cannot compute a deterministic request_hash: value of type "
209
+ f"'{type(value).__name__}' at '{path}' is not JSON-serializable. "
210
+ "Pass JSON-compatible values (dict/list/str/int/float/bool/None) or "
211
+ "objects exposing model_dump() (Pydantic v2)."
212
+ )
213
+
214
+
215
+ class ProviderAdapter:
216
+ """Generic engine that intercepts a provider call and enforces consent."""
217
+
218
+ def __init__(self, descriptor: ProviderDescriptor) -> None:
219
+ self.descriptor = descriptor
220
+
221
+ @property
222
+ def name(self) -> str:
223
+ return self.descriptor.name
224
+
225
+ def get_call_sites(self, client: Any) -> list[ResolvedCallSite]:
226
+ """Resolve every declared-and-present endpoint to intercept.
227
+
228
+ Returns ALL call sites declared by the descriptor that are actually
229
+ present on the client (e.g. both OpenAI entrypoints,
230
+ ``chat.completions.create`` and ``responses.create``). Every returned
231
+ site must be consent-enforced: a declared-but-unwrapped site would let
232
+ AI calls through without verification. Declared sites absent from the
233
+ client are skipped.
234
+
235
+ Each result carries the descriptor's :class:`CallSite`, so the caller
236
+ knows *which endpoint* it patched — the SDK tells the cloud, and the
237
+ cloud decides parameters per endpoint rather than per provider.
238
+
239
+ A site's ``aliases`` are resolved too, each as its own result carrying
240
+ the *same* :class:`CallSite`: ``chat.completions.parse`` reaches the same
241
+ HTTP endpoint as ``create``, so it is enforced under the same wire id and
242
+ the cloud is told nothing new. Absent aliases are skipped like absent
243
+ sites.
244
+ """
245
+ sites: list[ResolvedCallSite] = []
246
+ for site in self.descriptor.call_sites:
247
+ target = client
248
+ for attr in site.path[:-1]:
249
+ if not hasattr(target, attr):
250
+ break
251
+ target = getattr(target, attr)
252
+ else:
253
+ for method in (site.path[-1], *site.aliases):
254
+ if hasattr(target, method):
255
+ sites.append(ResolvedCallSite(target, method, site))
256
+ if not sites:
257
+ raise UnsupportedProviderError(
258
+ f"None of the call sites for provider '{self.descriptor.name}' "
259
+ f"exist on the given client."
260
+ )
261
+ return sites
262
+
263
+ def get_call_site(self, client: Any) -> ResolvedCallSite:
264
+ """Resolve the first declared-and-present endpoint.
265
+
266
+ Kept for backward compatibility; enforcement code must use
267
+ :meth:`get_call_sites` so that no declared entrypoint is left unwrapped.
268
+ """
269
+ return self.get_call_sites(client)[0]
270
+
271
+ def extract_request_metadata(self, **kwargs: Any) -> dict[str, Any]:
272
+ """Extract the model and the deterministic request hash for one call.
273
+
274
+ The hash is ``sha256:<hex>`` over the RFC 8785 (JCS) canonical form of
275
+ the caller's pre-enforcement payload — the call kwargs minus the two
276
+ identity parameters (``user_id``/``ubt``) and the per-request headers
277
+ kwarg. Cloud-forced parameters, local constraint transformations,
278
+ routed credentials and the ACT header are applied only after this hash
279
+ is computed. Shared test vectors in
280
+ ``packages/descriptors/testdata/`` pin this computation across SDK
281
+ languages. Values that cannot be normalized to JSON raise
282
+ :class:`ConsentTokenError` (never a silent ``repr()`` fallback), with two
283
+ exceptions that put nothing on the wire: a library's "not given"
284
+ sentinel (``omit``, ``NOT_GIVEN``, ``UNSET``), and a transport option
285
+ whose value is an object — ``timeout=httpx.Timeout(…)``,
286
+ ``retries=RetryConfig(…)`` — which says how the request travels, not
287
+ what it asks. Both used to raise, so leaving them out moves no hash.
288
+ """
289
+ ignored = {
290
+ self.descriptor.identity_param,
291
+ self.descriptor.ubt_param,
292
+ self.descriptor.headers_kwarg,
293
+ }
294
+ transport = transport_options(self.descriptor)
295
+ payload = {}
296
+ for key, value in kwargs.items():
297
+ # A sentinel is a library's "not passed": nothing goes on the wire
298
+ # for it, so nothing goes in the hash either.
299
+ if key in ignored or is_sentinel(value):
300
+ continue
301
+ try:
302
+ payload[key] = _normalize(value, key)
303
+ except ConsentTokenError:
304
+ # `timeout=httpx.Timeout(10)` or `retries=RetryConfig(...)` says
305
+ # how the request travels, not what it asks. Left out rather than
306
+ # stringified, and only when it cannot be written as JSON: a
307
+ # plain `timeout=5.0` was always hashed, and a hash that already
308
+ # proved a call must not move. No call carrying one of these
309
+ # objects ever had a hash, so leaving them out moves none.
310
+ if key not in transport:
311
+ raise
312
+ try:
313
+ blob = _jcs.canonicalize(payload)
314
+ except ValueError as exc:
315
+ raise ConsentTokenError(
316
+ f"Cannot compute a deterministic request_hash: {exc}"
317
+ ) from exc
318
+ request_hash = "sha256:" + hashlib.sha256(blob).hexdigest()
319
+ return {"model": kwargs.get("model", ""), "request_hash": request_hash}
320
+
321
+ def apply_constraints(
322
+ self,
323
+ kwargs: dict[str, Any],
324
+ constraints: dict[str, Any],
325
+ call_site: str | None = None,
326
+ ) -> dict[str, Any]:
327
+ """Honour the cloud's non-parameter constraints on this call.
328
+
329
+ A constraint is not a forced parameter and must not be one: forcing a
330
+ value writes the developer's request, which the cloud is never allowed
331
+ to do (see ``protected_params``). ``tools`` is the first and only
332
+ constraint — the mechanism the descriptors call
333
+ ``refuse_or_strip_tools``.
334
+
335
+ Note what is NOT sent to the cloud to make this work: nothing. The cloud
336
+ returns the constraint from the user's consent state alone and never
337
+ learns whether this particular call carried tools. That question is
338
+ answered here, on the caller's own machine, because answering it
339
+ anywhere else would mean telling us something about the request — and
340
+ the whole architecture rests on our not knowing.
341
+
342
+ What counts as a tool is wider than one key. ``tools_param`` is where a
343
+ provider takes tool definitions, and ``call_site`` names the endpoint
344
+ whose ``tool_fields`` list every other argument that can hand the model
345
+ one: ``functions``, ``web_search_options``, a stored conversation, a
346
+ Mistral agent, a Gemini cache. Without ``call_site``, the tool fields of
347
+ every call site the descriptor declares are read: a caller that does not
348
+ say which endpoint it is gets the wide reading, never the narrow one.
349
+
350
+ ``strip`` removes definitions, which the SDK can see and take out. Every
351
+ other kind is refused under ``strip`` too: an agent or a stored
352
+ conversation is sent whole or not at all, and removing it changes what
353
+ the call means.
354
+ """
355
+ action = constraints.get("tools")
356
+ if not action:
357
+ return kwargs
358
+
359
+ if action not in ("refuse", "strip"):
360
+ # An unknown instruction is not "no instruction". Guessing would be
361
+ # the one failure mode this whole path exists to prevent.
362
+ raise ConsentTokenError(
363
+ f"ID Side cloud returned an unknown tools constraint {action!r}; "
364
+ "the AI call was not made. Upgrade the idside package."
365
+ )
366
+
367
+ path = self.descriptor.tools_param
368
+ if path is None:
369
+ raise ConsentTokenError(
370
+ "Agentic tools are not accepted for this user, but the "
371
+ f"'{self.descriptor.name}' descriptor does not say where tools "
372
+ "travel on a call, so the SDK cannot check. The AI call was not "
373
+ "made — a refusal it cannot apply is not a refusal it may ignore."
374
+ )
375
+
376
+ site = self.descriptor.call_site(call_site) if call_site else None
377
+ sites = (site,) if site is not None else self.descriptor.call_sites
378
+ fields = tuple(
379
+ dict.fromkeys(
380
+ (
381
+ ToolField(path=path, kind="definitions"),
382
+ *(field for each in sites for field in each.tool_fields),
383
+ )
384
+ )
385
+ )
386
+ carried = [field for field in fields if _carries_tools(kwargs, field)]
387
+ if not carried:
388
+ return kwargs # no tools on this call: nothing to refuse
389
+
390
+ def _named(found: list[ToolField]) -> str:
391
+ return ", ".join(f"`{'.'.join(field.path)}`" for field in found)
392
+
393
+ # One message for whoever refused — the person, nobody asking them, or
394
+ # an organization's policy, which no answer lifts — because the SDK
395
+ # does not know which, and must not. Node's is the same, word for word.
396
+ refused = (
397
+ "Agentic tools are not accepted for this user (refused by the "
398
+ "person, never asked, or refused by a policy)"
399
+ )
400
+ # "Without them": the fields named, which are not always `tools` — told
401
+ # "without the tools" about `conversation`, a developer drops `tools`,
402
+ # keeps the reference and is refused again.
403
+ remedy = (
404
+ "The AI call was not made. Retry without them; never retry the same "
405
+ "call, it will be refused again."
406
+ )
407
+ if action == "refuse":
408
+ raise ToolUseRefusedError(
409
+ f"{refused}, and this call passes {_named(carried)}. {remedy}"
410
+ )
411
+ whole = [field for field in carried if field.kind != "definitions"]
412
+ if whole:
413
+ raise ToolUseRefusedError(
414
+ f"{refused}, and this call passes {_named(whole)}, which can bring "
415
+ "tools the SDK cannot see or remove without changing what the "
416
+ f"call asks. {remedy}"
417
+ )
418
+ for field in carried:
419
+ kwargs = _drop_path(kwargs, field.path)
420
+ return kwargs
421
+
422
+ def apply(
423
+ self,
424
+ kwargs: dict[str, Any],
425
+ params_forced: dict[str, Any],
426
+ token: str,
427
+ credential_headers: dict[str, str] | None = None,
428
+ ) -> dict[str, Any]:
429
+ """Return new call kwargs with cloud-decided params + ACT token injected.
430
+
431
+ ``params_forced`` keys listed in the descriptor's ``protected_params`` are
432
+ dropped (the cloud can never override core request fields). Injection goes
433
+ to kwargs or to per-request headers per the descriptor's ``param_target``;
434
+ the headers travel in the kwarg named by ``headers_kwarg``. Does not
435
+ mutate the input.
436
+
437
+ ``credential_headers`` selects which of the developer's credentials
438
+ carries this call (OpenAI's ``OpenAI-Project``), and is applied last: it
439
+ is decided from the developer's own configuration, so nothing the cloud
440
+ forces may overwrite it. An adapter overriding this method **must**
441
+ forward it — ``consent.wrap()`` verifies the header landed and refuses
442
+ the call otherwise, rather than let a routed request leave on the
443
+ credential it was meant to move off.
444
+ """
445
+ new = dict(kwargs)
446
+ safe = {
447
+ k: v
448
+ for k, v in params_forced.items()
449
+ if k not in self.descriptor.protected_params
450
+ }
451
+ injected_headers: dict[str, Any] = {}
452
+ if self.descriptor.param_target == "headers":
453
+ injected_headers.update(safe)
454
+ else:
455
+ new.update(safe)
456
+ injected_headers[self.descriptor.token_header] = token
457
+ if credential_headers:
458
+ injected_headers.update(credential_headers)
459
+ new[self.descriptor.headers_kwarg] = _merge_headers(
460
+ new.get(self.descriptor.headers_kwarg), injected_headers
461
+ )
462
+ return new
@@ -0,0 +1,89 @@
1
+ # Patents: https://sdk.idside.eu/patents
2
+ # This software may be covered by the patents and applications listed there.
3
+
4
+ """Built-in adapter for Google's ``google-genai`` client.
5
+
6
+ ``models.generate_content`` (and its ``_stream`` variant) accepts exactly
7
+ ``(model, contents, config)`` — no free-form kwargs — so the generic
8
+ kwargs/headers engine cannot inject anything at the top level. Per-request
9
+ headers ARE supported, but only nested as ``config.http_options.headers``
10
+ (the google-genai transport merges them over the client-level headers).
11
+
12
+ This adapter therefore overrides :meth:`apply` to (1) merge the cloud-forced
13
+ params into ``config`` — that is where Gemini's generation parameters live —
14
+ and (2) carry the ACT token in ``config.http_options.headers``. The caller's
15
+ ``config`` may be a plain dict or a ``GenerateContentConfig`` (Pydantic v2)
16
+ object; both are normalized to the dict form google-genai equally accepts,
17
+ without mutating the caller's objects.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from idside.adapters.base import ProviderAdapter, _merge_headers
25
+ from idside.exceptions import ConsentTokenError
26
+
27
+
28
+ def _as_dict(value: Any, what: str) -> dict[str, Any]:
29
+ """Return ``value`` as a fresh plain dict (None → {}, Pydantic → dump)."""
30
+ if value is None:
31
+ return {}
32
+ if isinstance(value, dict):
33
+ return dict(value)
34
+ model_dump = getattr(value, "model_dump", None)
35
+ if callable(model_dump):
36
+ # exclude_none: in google-genai config models, None means "unset".
37
+ dumped = model_dump(exclude_none=True)
38
+ if isinstance(dumped, dict):
39
+ return dumped
40
+ raise ConsentTokenError(
41
+ f"Cannot enforce consent on this Gemini call: {what} of type "
42
+ f"'{type(value).__name__}' is neither a dict nor a Pydantic model."
43
+ )
44
+
45
+
46
+ class GeminiAdapter(ProviderAdapter):
47
+ """Descriptor-driven engine specialized for ``google-genai`` mechanics."""
48
+
49
+ def apply(
50
+ self,
51
+ kwargs: dict[str, Any],
52
+ params_forced: dict[str, Any],
53
+ token: str,
54
+ credential_headers: dict[str, str] | None = None,
55
+ ) -> dict[str, Any]:
56
+ """Merge forced params into ``config``; token via ``http_options.headers``.
57
+
58
+ ``protected_params`` applies to the forced keys before they touch the
59
+ config, exactly as in the generic engine. Does not mutate the input.
60
+
61
+ ``credential_headers`` is carried in the same nested header map, for
62
+ completeness: google-genai reads no header that selects a project, so
63
+ Gemini routing means a second client rather than a header, and this stays
64
+ empty in practice.
65
+ """
66
+ new = dict(kwargs)
67
+ safe = {
68
+ k: v
69
+ for k, v in params_forced.items()
70
+ if k not in self.descriptor.protected_params
71
+ }
72
+ config = _as_dict(new.get("config"), "config")
73
+ config.update(safe)
74
+ # google-genai also takes `httpOptions`, and refuses a config holding
75
+ # both spellings: the token must join the caller's own options, not sit
76
+ # beside them under the other name.
77
+ http_options = {
78
+ **_as_dict(config.pop("httpOptions", None), "config.httpOptions"),
79
+ **_as_dict(config.get("http_options"), "config.http_options"),
80
+ }
81
+ injected_headers = {self.descriptor.token_header: token}
82
+ if credential_headers:
83
+ injected_headers.update(credential_headers)
84
+ http_options["headers"] = _merge_headers(
85
+ http_options.get("headers"), injected_headers
86
+ )
87
+ config["http_options"] = http_options
88
+ new["config"] = config
89
+ return new