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/adapters/base.py
ADDED
|
@@ -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
|