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
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
# Patents: https://sdk.idside.eu/patents
|
|
2
|
+
# This software may be covered by the patents and applications listed there.
|
|
3
|
+
|
|
4
|
+
"""The options a consent-enforced call may carry, and the ones it may not.
|
|
5
|
+
|
|
6
|
+
A provider library accepts two kinds of keyword on a call. Request parameters
|
|
7
|
+
— ``model``, ``messages``, ``tools``, ``store`` — are the request: the SDK hashes
|
|
8
|
+
them, the cloud forces some of them, and the proof describes them. Request
|
|
9
|
+
*options* are instructions to the library about how to send it, and several act
|
|
10
|
+
after everything the SDK does. ``extra_body`` is merged into the body once it is
|
|
11
|
+
built, so ``extra_body={"store": True}`` re-enabled the retention a user had
|
|
12
|
+
refused while the signed proof said ``store: false``. ``extra_query``,
|
|
13
|
+
``server_url`` and ``http_options.base_url`` move the request, the ACT token and
|
|
14
|
+
the API key to another address. A header can pick another credential.
|
|
15
|
+
|
|
16
|
+
So the options are a closed list, per library. An option is let through only
|
|
17
|
+
when it is known to change nothing a proof covers — a timeout, a retry policy,
|
|
18
|
+
a header map holding no reserved header — and everything else is refused with
|
|
19
|
+
:class:`UnenforceableOptionError` before the cloud is asked. Closed, rather
|
|
20
|
+
than a list of known offenders, because the offenders were found by
|
|
21
|
+
experiment, and the next version of a library can add one nobody has tried.
|
|
22
|
+
|
|
23
|
+
Telling an option from a parameter needs the library's own word for what the
|
|
24
|
+
body is: the body schema it validates the call against (a ``TypedDict`` for
|
|
25
|
+
the Stainless clients, a Pydantic request model for Speakeasy). It is read at
|
|
26
|
+
run time from the installed library, so an option added by a version newer
|
|
27
|
+
than this SDK is still refused, and a *parameter* added by one still goes out.
|
|
28
|
+
``tests/test_request_options.py`` computes the same split from the pinned
|
|
29
|
+
libraries and fails when one gains an option nobody has classified here.
|
|
30
|
+
|
|
31
|
+
A schema can also move — ``openai 7`` relocating ``CompletionCreateParamsBase``
|
|
32
|
+
would do it — and that is the same event by another name: a library version
|
|
33
|
+
newer than this SDK. So every classified library also carries ``body_fields``,
|
|
34
|
+
the schema as the pinned version declared it, and an unreadable schema falls
|
|
35
|
+
back on that rather than on anything open. Whatever cannot be read, the list of
|
|
36
|
+
what an enforced call may carry only ever narrows.
|
|
37
|
+
|
|
38
|
+
Nothing here reads a value to decide anything but its presence, and no message
|
|
39
|
+
repeats one: a header value is as likely as not a key.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import functools
|
|
45
|
+
import importlib
|
|
46
|
+
import re
|
|
47
|
+
from collections.abc import Mapping, Sequence
|
|
48
|
+
from typing import Any, NamedTuple
|
|
49
|
+
|
|
50
|
+
from idside.exceptions import (
|
|
51
|
+
CredentialRoutingError,
|
|
52
|
+
UnenforceableOptionError,
|
|
53
|
+
UnsupportedProviderError,
|
|
54
|
+
)
|
|
55
|
+
from idside.providers import ProviderDescriptor
|
|
56
|
+
|
|
57
|
+
#: The class names the provider libraries give their "not given" sentinels:
|
|
58
|
+
#: ``NOT_GIVEN`` and ``omit`` in openai and anthropic, ``UNSET`` in mistralai.
|
|
59
|
+
#: Named rather than inferred from falsiness, because a falsy value of some
|
|
60
|
+
#: other class — ``numpy.float64(0.0)`` for a temperature — is a real argument.
|
|
61
|
+
_SENTINEL_TYPES = frozenset({"NotGiven", "Omit", "Unset"})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_sentinel(value: Any) -> bool:
|
|
65
|
+
"""Whether ``value`` is a library's way of saying "this was not passed"."""
|
|
66
|
+
return type(value).__name__ in _SENTINEL_TYPES and not value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_not_given(value: Any) -> bool:
|
|
70
|
+
"""``None`` or a sentinel: an argument that asks for nothing."""
|
|
71
|
+
return value is None or is_sentinel(value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Library(NamedTuple):
|
|
75
|
+
"""What one client library's calls accept besides their body.
|
|
76
|
+
|
|
77
|
+
``permitted`` is the whole list of options that pass. ``refused`` names the
|
|
78
|
+
ones known to rewrite or redirect the request, and is what the trip-wire
|
|
79
|
+
test checks the library against. ``transport`` are the permitted options
|
|
80
|
+
whose value is an object rather than JSON (``httpx.Timeout``,
|
|
81
|
+
``RetryConfig``): they decide how the request travels, not what it says, and
|
|
82
|
+
stay out of the hash when they cannot be written into it. ``body_schemas``
|
|
83
|
+
maps a call site to where the library declares that endpoint's body, as
|
|
84
|
+
``module:attribute`` candidates tried in order.
|
|
85
|
+
|
|
86
|
+
``body_fields`` is the same map, resolved: the field names the pinned
|
|
87
|
+
library declared when this release was cut. It exists for the day a library
|
|
88
|
+
moves its schema — which is to say, for a version newer than this SDK, the
|
|
89
|
+
one case invariant #8 is about. Reading the live schema is what lets a
|
|
90
|
+
parameter invented after this release still go out; falling back on a list
|
|
91
|
+
of known offenders when it cannot be read let every *option* invented after
|
|
92
|
+
this release go out too, on every call, silently. So the fallback is this
|
|
93
|
+
snapshot, which is closed: a new option is in neither it nor ``permitted``.
|
|
94
|
+
``tests/test_request_options.py`` compares it with the live schema, so it
|
|
95
|
+
fails the day the two drift rather than the day a customer finds out.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
permitted: frozenset[str]
|
|
99
|
+
refused: frozenset[str]
|
|
100
|
+
transport: frozenset[str]
|
|
101
|
+
body_schemas: Mapping[str, tuple[str, ...]]
|
|
102
|
+
body_fields: Mapping[str, frozenset[str]]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fields(names: str) -> frozenset[str]:
|
|
106
|
+
"""One endpoint's frozen body field names, written as one string.
|
|
107
|
+
|
|
108
|
+
Generated from the installed library, kept in the source rather than in a
|
|
109
|
+
data file so that a change to what an enforced call may carry shows up in a
|
|
110
|
+
diff someone reviews.
|
|
111
|
+
"""
|
|
112
|
+
return frozenset(names.split())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _mistral_schema(model: str) -> tuple[str, ...]:
|
|
116
|
+
# mistralai 2 moved its models under `client`; 1.x kept them at the root.
|
|
117
|
+
return (f"mistralai.client.models:{model}", f"mistralai.models:{model}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
#: Keyed by the descriptor's ``client_module``: a library, not a provider.
|
|
121
|
+
#: DeepSeek is reached through the ``openai`` client and inherits its options.
|
|
122
|
+
LIBRARIES: Mapping[str, _Library] = {
|
|
123
|
+
"openai": _Library(
|
|
124
|
+
permitted=frozenset(
|
|
125
|
+
{
|
|
126
|
+
"timeout",
|
|
127
|
+
"extra_headers",
|
|
128
|
+
# Helper arguments of `parse` and `stream`: they shape what the
|
|
129
|
+
# library builds or reads, and none of them is a way around it.
|
|
130
|
+
"text_format",
|
|
131
|
+
"verbosity",
|
|
132
|
+
"response_id",
|
|
133
|
+
"starting_after",
|
|
134
|
+
}
|
|
135
|
+
),
|
|
136
|
+
refused=frozenset({"extra_body", "extra_query"}),
|
|
137
|
+
transport=frozenset({"timeout"}),
|
|
138
|
+
body_schemas={
|
|
139
|
+
"chat.completions.create": (
|
|
140
|
+
"openai.types.chat.completion_create_params:CompletionCreateParamsBase",
|
|
141
|
+
),
|
|
142
|
+
"responses.create": (
|
|
143
|
+
"openai.types.responses.response_create_params"
|
|
144
|
+
":ResponseCreateParamsBase",
|
|
145
|
+
),
|
|
146
|
+
},
|
|
147
|
+
body_fields={
|
|
148
|
+
"chat.completions.create": _fields(
|
|
149
|
+
"audio frequency_penalty function_call functions logit_bias "
|
|
150
|
+
"logprobs max_completion_tokens max_tokens messages "
|
|
151
|
+
"metadata modalities model moderation n parallel_tool_calls "
|
|
152
|
+
"prediction presence_penalty prompt_cache_key "
|
|
153
|
+
"prompt_cache_retention reasoning_effort response_format "
|
|
154
|
+
"safety_identifier seed service_tier stop store "
|
|
155
|
+
"stream_options temperature tool_choice tools top_logprobs "
|
|
156
|
+
"top_p user verbosity web_search_options"
|
|
157
|
+
),
|
|
158
|
+
"responses.create": _fields(
|
|
159
|
+
"background context_management conversation include input "
|
|
160
|
+
"instructions max_output_tokens max_tool_calls metadata "
|
|
161
|
+
"model moderation parallel_tool_calls previous_response_id "
|
|
162
|
+
"prompt prompt_cache_key prompt_cache_retention reasoning "
|
|
163
|
+
"safety_identifier service_tier store stream_options "
|
|
164
|
+
"temperature text tool_choice tools top_logprobs top_p "
|
|
165
|
+
"truncation user"
|
|
166
|
+
),
|
|
167
|
+
},
|
|
168
|
+
),
|
|
169
|
+
"anthropic": _Library(
|
|
170
|
+
permitted=frozenset({"timeout", "extra_headers", "output_format"}),
|
|
171
|
+
refused=frozenset({"extra_body", "extra_query"}),
|
|
172
|
+
transport=frozenset({"timeout"}),
|
|
173
|
+
body_schemas={
|
|
174
|
+
"messages.create": (
|
|
175
|
+
"anthropic.types.message_create_params:MessageCreateParamsBase",
|
|
176
|
+
),
|
|
177
|
+
},
|
|
178
|
+
body_fields={
|
|
179
|
+
"messages.create": _fields(
|
|
180
|
+
"cache_control container inference_geo max_tokens messages "
|
|
181
|
+
"metadata model output_config service_tier stop_sequences "
|
|
182
|
+
"system temperature thinking tool_choice tools top_k top_p"
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
),
|
|
186
|
+
"mistralai": _Library(
|
|
187
|
+
permitted=frozenset({"http_headers", "retries", "timeout_ms"}),
|
|
188
|
+
refused=frozenset({"server_url"}),
|
|
189
|
+
transport=frozenset({"retries", "timeout_ms"}),
|
|
190
|
+
body_schemas={
|
|
191
|
+
"chat.complete": _mistral_schema("ChatCompletionRequest"),
|
|
192
|
+
"chat.stream": _mistral_schema("ChatCompletionStreamRequest"),
|
|
193
|
+
"fim.complete": _mistral_schema("FIMCompletionRequest"),
|
|
194
|
+
"fim.stream": _mistral_schema("FIMCompletionStreamRequest"),
|
|
195
|
+
"agents.complete": _mistral_schema("AgentsCompletionRequest"),
|
|
196
|
+
"agents.stream": _mistral_schema("AgentsCompletionStreamRequest"),
|
|
197
|
+
"beta.conversations.start": _mistral_schema("ConversationRequest"),
|
|
198
|
+
},
|
|
199
|
+
body_fields={
|
|
200
|
+
"chat.complete": _fields(
|
|
201
|
+
"frequency_penalty guardrails max_tokens messages metadata "
|
|
202
|
+
"model n parallel_tool_calls prediction presence_penalty "
|
|
203
|
+
"prompt_cache_key prompt_mode random_seed reasoning_effort "
|
|
204
|
+
"response_format safe_prompt stop stream temperature "
|
|
205
|
+
"tool_choice tools top_p"
|
|
206
|
+
),
|
|
207
|
+
"chat.stream": _fields(
|
|
208
|
+
"frequency_penalty guardrails max_tokens messages metadata "
|
|
209
|
+
"model n parallel_tool_calls prediction presence_penalty "
|
|
210
|
+
"prompt_cache_key prompt_mode random_seed reasoning_effort "
|
|
211
|
+
"response_format safe_prompt stop stream temperature "
|
|
212
|
+
"tool_choice tools top_p"
|
|
213
|
+
),
|
|
214
|
+
"fim.complete": _fields(
|
|
215
|
+
"max_tokens metadata min_tokens model prompt "
|
|
216
|
+
"prompt_cache_key random_seed stop stream suffix "
|
|
217
|
+
"temperature top_p"
|
|
218
|
+
),
|
|
219
|
+
"fim.stream": _fields(
|
|
220
|
+
"max_tokens metadata min_tokens model prompt "
|
|
221
|
+
"prompt_cache_key random_seed stop stream suffix "
|
|
222
|
+
"temperature top_p"
|
|
223
|
+
),
|
|
224
|
+
"agents.complete": _fields(
|
|
225
|
+
"agent_id frequency_penalty guardrails max_tokens messages "
|
|
226
|
+
"metadata n parallel_tool_calls prediction presence_penalty "
|
|
227
|
+
"prompt_cache_key prompt_mode random_seed reasoning_effort "
|
|
228
|
+
"response_format stop stream tool_choice tools"
|
|
229
|
+
),
|
|
230
|
+
"agents.stream": _fields(
|
|
231
|
+
"agent_id frequency_penalty guardrails max_tokens messages "
|
|
232
|
+
"metadata n parallel_tool_calls prediction presence_penalty "
|
|
233
|
+
"prompt_cache_key prompt_mode random_seed reasoning_effort "
|
|
234
|
+
"response_format stop stream tool_choice tools"
|
|
235
|
+
),
|
|
236
|
+
"beta.conversations.start": _fields(
|
|
237
|
+
"agent_id agent_version completion_args description "
|
|
238
|
+
"guardrails handoff_execution inputs instructions metadata "
|
|
239
|
+
"model name store stream tools"
|
|
240
|
+
),
|
|
241
|
+
},
|
|
242
|
+
),
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#: ``google-genai`` takes exactly ``model``, ``contents`` and ``config``, so its
|
|
246
|
+
#: options live inside ``config``: the fields no request converter reads. All
|
|
247
|
+
#: three pass, and only one is read further. ``should_return_http_response``
|
|
248
|
+
#: changes what the library hands back, not what it sends.
|
|
249
|
+
#: ``automatic_function_calling`` configures a loop that only runs when a tool
|
|
250
|
+
#: is a local callable — and that is refused on its own, whatever this says,
|
|
251
|
+
#: because one token would then cover several requests. ``http_options`` is
|
|
252
|
+
#: inspected field by field below.
|
|
253
|
+
GENAI_MODULE = "google.genai"
|
|
254
|
+
GENAI_CONFIG_OPTIONS = frozenset(
|
|
255
|
+
{"http_options", "should_return_http_response", "automatic_function_calling"}
|
|
256
|
+
)
|
|
257
|
+
#: The only arguments ``models.generate_content(_stream)`` accepts. Anything
|
|
258
|
+
#: else is refused before the cloud is asked, rather than failing as a
|
|
259
|
+
#: ``TypeError`` inside the provider library after a proof was signed for it.
|
|
260
|
+
GENAI_ARGUMENTS = frozenset({"model", "contents", "config"})
|
|
261
|
+
#: ``config.http_options`` holds twelve fields and three of them are harmless.
|
|
262
|
+
#: The other nine choose the address, the path, the body or the HTTP client
|
|
263
|
+
#: itself.
|
|
264
|
+
GENAI_HTTP_PERMITTED = frozenset({"headers", "timeout", "retry_options"})
|
|
265
|
+
GENAI_HTTP_REFUSED = frozenset(
|
|
266
|
+
{
|
|
267
|
+
"base_url",
|
|
268
|
+
"base_url_resource_scope",
|
|
269
|
+
"api_version",
|
|
270
|
+
"client_args",
|
|
271
|
+
"async_client_args",
|
|
272
|
+
"extra_body",
|
|
273
|
+
"httpx_client",
|
|
274
|
+
"httpx_async_client",
|
|
275
|
+
"aiohttp_client",
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
#: Refused for a library this SDK has no classification for at all. Not a closed
|
|
280
|
+
#: list — with neither a schema nor a snapshot, a new option cannot be told from
|
|
281
|
+
#: a new body field — but the options every client library in this space uses to
|
|
282
|
+
#: rewrite or redirect a request are not left open on that account.
|
|
283
|
+
#: ``http_options`` (top level, or inside ``config``) is inspected field by field
|
|
284
|
+
#: as for Gemini.
|
|
285
|
+
#:
|
|
286
|
+
#: A CLASSIFIED library never reaches this list. That it used to, whenever the
|
|
287
|
+
#: live body schema could not be read, was a hole with exactly one trigger: a
|
|
288
|
+
#: library version newer than this SDK, moving its schema. From that version on,
|
|
289
|
+
#: every option nobody had classified passed on every call — the case invariant
|
|
290
|
+
#: #8 is written for — and the trip-wire could not see it, since it only ever
|
|
291
|
+
#: runs against the pinned version. The fallback is now ``body_fields``.
|
|
292
|
+
KNOWN_OFFENDERS = frozenset({"extra_body", "extra_query", "server_url", "base_url"})
|
|
293
|
+
|
|
294
|
+
#: Why a known option is refused, in words a developer can act on.
|
|
295
|
+
_WHY = {
|
|
296
|
+
"extra_body": "rewrites the request body after it has been authorized",
|
|
297
|
+
"extra_query": "adds to the URL of a request that has already been authorized",
|
|
298
|
+
"server_url": "sends the request to a server this client was not wrapped for",
|
|
299
|
+
"base_url": "sends the request to a server this client was not wrapped for",
|
|
300
|
+
"base_url_resource_scope": "changes the URL the request is sent to",
|
|
301
|
+
"api_version": "rewrites the request path, and with it the model it reaches",
|
|
302
|
+
"client_args": "builds another HTTP client for this request",
|
|
303
|
+
"async_client_args": "builds another HTTP client for this request",
|
|
304
|
+
"httpx_client": "sends this request on another HTTP client",
|
|
305
|
+
"httpx_async_client": "sends this request on another HTTP client",
|
|
306
|
+
"aiohttp_client": "sends this request on another HTTP client",
|
|
307
|
+
}
|
|
308
|
+
_WHY_UNKNOWN = "is not an option this SDK knows to leave the request as authorized"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def transport_options(descriptor: ProviderDescriptor) -> frozenset[str]:
|
|
312
|
+
"""Options whose non-JSON value is left out of the request hash."""
|
|
313
|
+
library = LIBRARIES.get(descriptor.client_module)
|
|
314
|
+
return library.transport if library is not None else frozenset()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def refuse_unenforceable(
|
|
318
|
+
descriptor: ProviderDescriptor, call_site: str, kwargs: Mapping[str, Any]
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Raise if this call carries an option enforcement cannot stand behind.
|
|
321
|
+
|
|
322
|
+
``kwargs`` are the call's arguments with the identity already consumed.
|
|
323
|
+
Raises :class:`UnenforceableOptionError`; returns nothing otherwise.
|
|
324
|
+
"""
|
|
325
|
+
_refuse_reserved_headers(
|
|
326
|
+
descriptor, descriptor.headers_kwarg, kwargs.get(descriptor.headers_kwarg)
|
|
327
|
+
)
|
|
328
|
+
if descriptor.client_module == GENAI_MODULE:
|
|
329
|
+
for name, value in kwargs.items():
|
|
330
|
+
if name not in GENAI_ARGUMENTS and not is_not_given(value):
|
|
331
|
+
_refuse_option(name, name)
|
|
332
|
+
_refuse_genai_config(descriptor, kwargs.get("config"))
|
|
333
|
+
return
|
|
334
|
+
library = LIBRARIES.get(descriptor.client_module)
|
|
335
|
+
if library is None:
|
|
336
|
+
_refuse_known_offenders(descriptor, kwargs)
|
|
337
|
+
return
|
|
338
|
+
body = _body_fields(library.body_schemas.get(call_site, ()))
|
|
339
|
+
if body is None:
|
|
340
|
+
body = _frozen_body(descriptor.client_module, call_site)
|
|
341
|
+
for name, value in kwargs.items():
|
|
342
|
+
if name in library.permitted or name == "stream" or is_not_given(value):
|
|
343
|
+
continue
|
|
344
|
+
if name not in body:
|
|
345
|
+
_refuse_option(name, name)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@functools.cache
|
|
349
|
+
def _frozen_body(module: str, call_site: str) -> frozenset[str]:
|
|
350
|
+
"""What this SDK was built to accept on an endpoint whose schema will not read.
|
|
351
|
+
|
|
352
|
+
The endpoint's own snapshot when there is one. Otherwise everything the
|
|
353
|
+
library declares anywhere — the narrowest thing that can honestly be said
|
|
354
|
+
about an endpoint nobody classified, and still closed: an option a newer
|
|
355
|
+
version adds is in none of its endpoints' bodies, so it is refused, while
|
|
356
|
+
the ordinary parameters of a call go out as they always did. Passing a
|
|
357
|
+
parameter of a *different* endpoint is then the library's own ``TypeError``,
|
|
358
|
+
which is the developer's mistake and not a proof describing another request.
|
|
359
|
+
"""
|
|
360
|
+
library = LIBRARIES[module]
|
|
361
|
+
frozen = library.body_fields.get(call_site)
|
|
362
|
+
if frozen is not None:
|
|
363
|
+
return frozen
|
|
364
|
+
return frozenset().union(*library.body_fields.values())
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _refuse_known_offenders(
|
|
368
|
+
descriptor: ProviderDescriptor, kwargs: Mapping[str, Any]
|
|
369
|
+
) -> None:
|
|
370
|
+
"""The fallback for a library this SDK classifies nothing about.
|
|
371
|
+
|
|
372
|
+
Refuses the names known to rewrite or redirect a request, and reads any
|
|
373
|
+
``http_options`` — given directly or inside ``config`` — as google-genai's.
|
|
374
|
+
"""
|
|
375
|
+
for name, value in kwargs.items():
|
|
376
|
+
if is_not_given(value):
|
|
377
|
+
continue
|
|
378
|
+
if name in KNOWN_OFFENDERS:
|
|
379
|
+
_refuse_option(name, name)
|
|
380
|
+
elif name == "http_options":
|
|
381
|
+
_refuse_http_options(descriptor, "http_options", value)
|
|
382
|
+
elif name == "config":
|
|
383
|
+
for raw_name, nested in (mapping_view(value) or {}).items():
|
|
384
|
+
if _snake(str(raw_name)) == "http_options" and not is_not_given(nested):
|
|
385
|
+
_refuse_http_options(descriptor, "config.http_options", nested)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@functools.cache
|
|
389
|
+
def _body_fields(candidates: tuple[str, ...]) -> frozenset[str] | None:
|
|
390
|
+
"""The body fields a library declares for one endpoint, or None.
|
|
391
|
+
|
|
392
|
+
Cached per endpoint: the schema is a class definition, and it does not
|
|
393
|
+
change while the process runs.
|
|
394
|
+
"""
|
|
395
|
+
for candidate in candidates:
|
|
396
|
+
module_name, _, attribute = candidate.partition(":")
|
|
397
|
+
try:
|
|
398
|
+
schema = getattr(importlib.import_module(module_name), attribute)
|
|
399
|
+
except (ImportError, AttributeError):
|
|
400
|
+
continue
|
|
401
|
+
fields = getattr(schema, "model_fields", None)
|
|
402
|
+
if isinstance(fields, Mapping):
|
|
403
|
+
return frozenset(fields)
|
|
404
|
+
required = getattr(schema, "__required_keys__", None)
|
|
405
|
+
optional = getattr(schema, "__optional_keys__", None)
|
|
406
|
+
if isinstance(required, frozenset) and isinstance(optional, frozenset):
|
|
407
|
+
return frozenset(required | optional)
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _refuse_option(where: str, name: str) -> None:
|
|
412
|
+
raise UnenforceableOptionError(
|
|
413
|
+
f"`{where}` cannot be used on a consent-enforced call: it "
|
|
414
|
+
f"{_WHY.get(name, _WHY_UNKNOWN)}, so the request leaving the process "
|
|
415
|
+
"would not be the one the proof describes. The AI call was not made and "
|
|
416
|
+
"ID Side was not asked. Pass request parameters as named arguments of the "
|
|
417
|
+
"call, and set where the client sends when you build it."
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class _Derivation(NamedTuple):
|
|
422
|
+
"""What ``with_options()``/``copy()`` may be asked on one library's clients.
|
|
423
|
+
|
|
424
|
+
A derived client is built in one line from the wrapped one, and its
|
|
425
|
+
construction options redo, for every call it will ever make, what the
|
|
426
|
+
per-call list refuses: ``http_client=`` brings a transport of its own,
|
|
427
|
+
``default_query=`` rewrites every URL, ``api_key=`` or ``project=`` moves the
|
|
428
|
+
calls onto another credential, ``middleware=`` rewrites requests. So these
|
|
429
|
+
are a closed list too, classified parameter by parameter against the real
|
|
430
|
+
signatures (``tests/test_request_options.py`` pins them):
|
|
431
|
+
|
|
432
|
+
- ``harmless`` pass: they touch neither the transport, the credential, the
|
|
433
|
+
URL nor the headers;
|
|
434
|
+
- ``checked`` pass when the result holds: ``base_url`` must leave the origin
|
|
435
|
+
unchanged, a header map may hold no reserved header, and a ``middleware``
|
|
436
|
+
list must be the one the client already runs;
|
|
437
|
+
- ``credentials`` are refused with ``CredentialRoutingError``: which
|
|
438
|
+
credential carries a call is routing's to decide;
|
|
439
|
+
- everything else, known or not, is refused with
|
|
440
|
+
``UnsupportedProviderError``: a client that sends differently is a new
|
|
441
|
+
client, to be built and wrapped on its own.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
harmless: frozenset[str]
|
|
445
|
+
checked: frozenset[str]
|
|
446
|
+
credentials: frozenset[str]
|
|
447
|
+
unsupported: frozenset[str]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_DERIVED_HEADERS = frozenset({"default_headers", "set_default_headers"})
|
|
451
|
+
|
|
452
|
+
#: The one derivation option judged against the client it is derived FROM, and
|
|
453
|
+
#: the only one a library passes to ``copy()`` on the developer's behalf.
|
|
454
|
+
_MIDDLEWARE = "middleware"
|
|
455
|
+
|
|
456
|
+
DERIVATIONS: Mapping[str, _Derivation] = {
|
|
457
|
+
"openai": _Derivation(
|
|
458
|
+
harmless=frozenset({"timeout", "max_retries", "webhook_secret"}),
|
|
459
|
+
checked=frozenset({"base_url"}) | _DERIVED_HEADERS,
|
|
460
|
+
credentials=frozenset(
|
|
461
|
+
{
|
|
462
|
+
"api_key",
|
|
463
|
+
"admin_api_key",
|
|
464
|
+
"workload_identity",
|
|
465
|
+
"organization",
|
|
466
|
+
"project",
|
|
467
|
+
"azure_ad_token",
|
|
468
|
+
"azure_ad_token_provider",
|
|
469
|
+
}
|
|
470
|
+
),
|
|
471
|
+
unsupported=frozenset(
|
|
472
|
+
{
|
|
473
|
+
"http_client",
|
|
474
|
+
"default_query",
|
|
475
|
+
"set_default_query",
|
|
476
|
+
"websocket_base_url",
|
|
477
|
+
"api_version",
|
|
478
|
+
"_enforce_credentials",
|
|
479
|
+
"_extra_kwargs",
|
|
480
|
+
}
|
|
481
|
+
),
|
|
482
|
+
),
|
|
483
|
+
"anthropic": _Derivation(
|
|
484
|
+
harmless=frozenset({"timeout", "max_retries", "webhook_key"}),
|
|
485
|
+
checked=frozenset({"base_url", _MIDDLEWARE}) | _DERIVED_HEADERS,
|
|
486
|
+
credentials=frozenset(
|
|
487
|
+
{
|
|
488
|
+
"api_key",
|
|
489
|
+
"auth_token",
|
|
490
|
+
"credentials",
|
|
491
|
+
"config",
|
|
492
|
+
"profile",
|
|
493
|
+
"aws_access_key",
|
|
494
|
+
"aws_secret_key",
|
|
495
|
+
"aws_session_token",
|
|
496
|
+
"aws_profile",
|
|
497
|
+
"access_token",
|
|
498
|
+
"project_id",
|
|
499
|
+
"azure_ad_token_provider",
|
|
500
|
+
"workspace_id",
|
|
501
|
+
"skip_auth",
|
|
502
|
+
}
|
|
503
|
+
),
|
|
504
|
+
unsupported=frozenset(
|
|
505
|
+
{
|
|
506
|
+
"http_client",
|
|
507
|
+
"default_query",
|
|
508
|
+
"set_default_query",
|
|
509
|
+
"region",
|
|
510
|
+
"aws_region",
|
|
511
|
+
"_extra_kwargs",
|
|
512
|
+
}
|
|
513
|
+
),
|
|
514
|
+
),
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
#: For a library ``DERIVATIONS`` does not classify: the same closed list, reduced
|
|
519
|
+
#: to what every client library names alike. Timeouts and retries pass,
|
|
520
|
+
#: ``base_url`` and the header maps are checked, every credential any known
|
|
521
|
+
#: library takes is routing's, and everything else is refused.
|
|
522
|
+
GENERIC_DERIVATION = _Derivation(
|
|
523
|
+
harmless=frozenset({"timeout", "max_retries"}),
|
|
524
|
+
checked=frozenset({"base_url"}) | _DERIVED_HEADERS,
|
|
525
|
+
credentials=frozenset().union(*(d.credentials for d in DERIVATIONS.values())),
|
|
526
|
+
unsupported=frozenset(),
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _arguments(
|
|
531
|
+
method: str, args: tuple[Any, ...], kwargs: Mapping[str, Any]
|
|
532
|
+
) -> list[tuple[str, str, Any]]:
|
|
533
|
+
"""Each argument of a derivation as (option, what to call it, value).
|
|
534
|
+
|
|
535
|
+
The middle element is how the developer wrote the call, and it is carried
|
|
536
|
+
rather than rebuilt because those two can differ. ``with_middleware(m)``
|
|
537
|
+
passes its middleware positionally, and an error naming a keyword the
|
|
538
|
+
developer did not write — worse, naming ``copy()``, the method the library
|
|
539
|
+
reaches it through — sends them looking for a line that does not exist.
|
|
540
|
+
"""
|
|
541
|
+
given = [(name, f"{method}({name}=…)", value) for name, value in kwargs.items()]
|
|
542
|
+
if args:
|
|
543
|
+
# `with_middleware(*middleware)` is the only positional derivation, and
|
|
544
|
+
# the arguments of any other one cannot be told apart.
|
|
545
|
+
if method == "with_middleware":
|
|
546
|
+
given.append((_MIDDLEWARE, f"{method}(…)", args))
|
|
547
|
+
else:
|
|
548
|
+
given.append(("*args", f"{method}(*args=…)", args))
|
|
549
|
+
return given
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def refuse_underivable(
|
|
553
|
+
descriptor: ProviderDescriptor,
|
|
554
|
+
method: str,
|
|
555
|
+
args: tuple[Any, ...],
|
|
556
|
+
kwargs: Mapping[str, Any],
|
|
557
|
+
client: Any = None,
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Raise if a derivation asks for something a derived client may not do.
|
|
560
|
+
|
|
561
|
+
Checked on the arguments, before the library builds anything. What can only
|
|
562
|
+
be judged on the result — the origin ``base_url`` resolves to, the headers
|
|
563
|
+
left once merged — is checked by ``consent.wrap()`` on the derived client.
|
|
564
|
+
|
|
565
|
+
``client`` is the client being derived FROM, which one option has to be read
|
|
566
|
+
against: middleware is judged on what it would *change*, and only the parent
|
|
567
|
+
says what it is changing from.
|
|
568
|
+
"""
|
|
569
|
+
derivation = DERIVATIONS.get(descriptor.client_module, GENERIC_DERIVATION)
|
|
570
|
+
for name, shown, value in _arguments(method, args, kwargs):
|
|
571
|
+
if is_not_given(value) or name in derivation.harmless:
|
|
572
|
+
continue
|
|
573
|
+
if name in _DERIVED_HEADERS:
|
|
574
|
+
_refuse_derived_headers(descriptor, shown, value)
|
|
575
|
+
elif name == _MIDDLEWARE and name in derivation.checked:
|
|
576
|
+
_refuse_new_middleware(shown, client, value)
|
|
577
|
+
elif name in derivation.checked:
|
|
578
|
+
continue
|
|
579
|
+
elif name in derivation.credentials:
|
|
580
|
+
raise CredentialRoutingError(
|
|
581
|
+
f"`{shown}` would put the derived client on another "
|
|
582
|
+
"credential. Which credential carries a call is decided by "
|
|
583
|
+
"credential routing from the user's answer, never by a derived "
|
|
584
|
+
"client: provision each credential and pass them to "
|
|
585
|
+
"consent.wrap(client, credentials={…}) instead. The client was not "
|
|
586
|
+
"derived."
|
|
587
|
+
)
|
|
588
|
+
else:
|
|
589
|
+
raise UnsupportedProviderError(
|
|
590
|
+
f"`{shown}` would change how or where the derived client "
|
|
591
|
+
"sends — its transport, its URL or what runs before the request — "
|
|
592
|
+
"and a derived client inherits the enforcement of the one it came "
|
|
593
|
+
"from, not a different way out. Build a new client with that option "
|
|
594
|
+
"and wrap it with consent.wrap(), so its provider is detected and "
|
|
595
|
+
"its destination frozen afresh. The client was not derived."
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _refuse_new_middleware(shown: str, client: Any, middleware: Any) -> None:
|
|
600
|
+
"""Let a middleware argument through only when it changes nothing.
|
|
601
|
+
|
|
602
|
+
Middleware is application code the library runs between a request and the
|
|
603
|
+
wire, after this SDK has authorized the call and below everything it checks,
|
|
604
|
+
so a derived client that gains some sends differently from the one that was
|
|
605
|
+
wrapped and needs a wrap of its own.
|
|
606
|
+
|
|
607
|
+
Asking for the middleware a client already runs changes nothing, and it is
|
|
608
|
+
what the library's own ``with_middleware()`` does when it is handed none:
|
|
609
|
+
``copy(middleware=[*self._middleware])``. Refusing that refused a derivation
|
|
610
|
+
that adds nothing, over an argument the developer never wrote.
|
|
611
|
+
|
|
612
|
+
Compared by identity and never by ``==``: an object is free to call itself
|
|
613
|
+
equal to whatever it likes, and this decides whose code runs under an
|
|
614
|
+
authorized call. ``None`` for ``client`` — nobody said what it is derived
|
|
615
|
+
from — can only mean refuse.
|
|
616
|
+
"""
|
|
617
|
+
current = getattr(client, "_middleware", None)
|
|
618
|
+
if (
|
|
619
|
+
isinstance(middleware, (list, tuple))
|
|
620
|
+
and isinstance(current, (list, tuple))
|
|
621
|
+
and len(middleware) == len(current)
|
|
622
|
+
and all(new is old for new, old in zip(middleware, current, strict=True))
|
|
623
|
+
):
|
|
624
|
+
return
|
|
625
|
+
raise UnsupportedProviderError(
|
|
626
|
+
f"`{shown}` would put middleware of its own between the derived client's "
|
|
627
|
+
"requests and the wire: code running after ID Side has authorized a call "
|
|
628
|
+
"and below everything the SDK checks. A derived client inherits the "
|
|
629
|
+
"enforcement of the one it came from, not a different way out. Build a new "
|
|
630
|
+
"client with that middleware and wrap it with consent.wrap(), so its "
|
|
631
|
+
"provider is detected and its destination frozen afresh. The client was "
|
|
632
|
+
"not derived."
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _refuse_derived_headers(
|
|
637
|
+
descriptor: ProviderDescriptor, shown: str, headers: Any
|
|
638
|
+
) -> None:
|
|
639
|
+
names = header_names(headers)
|
|
640
|
+
reserved = (
|
|
641
|
+
[header for header in names if descriptor.is_reserved_header(header)]
|
|
642
|
+
if names is not None
|
|
643
|
+
else ["an unreadable header map"]
|
|
644
|
+
)
|
|
645
|
+
if reserved:
|
|
646
|
+
raise CredentialRoutingError(
|
|
647
|
+
f"`{shown}` sets {reserved[0]!s}, which a derived client may "
|
|
648
|
+
"not: those headers choose the credential or carry the consent token, "
|
|
649
|
+
"and credential routing is what sets them. Build the client with them "
|
|
650
|
+
"instead, or route with consent.wrap(client, credentials={…}). The "
|
|
651
|
+
"client was not derived."
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def header_names(headers: Any) -> list[str] | None:
|
|
656
|
+
"""Every header name in a header map, or None if it cannot be read.
|
|
657
|
+
|
|
658
|
+
Every shape a library accepts: a mapping (``httpx.Headers`` included), a
|
|
659
|
+
sequence of pairs, names as ``bytes``.
|
|
660
|
+
|
|
661
|
+
A pair is a tuple or a list, never any sequence of length two: ``"Au"`` has
|
|
662
|
+
one, and ``["Au", "th"]`` read as the headers ``A`` and ``t`` — nothing
|
|
663
|
+
reserved, so nothing refused, from a map nobody can read.
|
|
664
|
+
"""
|
|
665
|
+
if isinstance(headers, Mapping):
|
|
666
|
+
raw: list[Any] = list(headers.keys())
|
|
667
|
+
elif isinstance(headers, Sequence) and not isinstance(headers, (str, bytes)):
|
|
668
|
+
if not all(
|
|
669
|
+
isinstance(pair, (tuple, list)) and len(pair) == 2 for pair in headers
|
|
670
|
+
):
|
|
671
|
+
return None
|
|
672
|
+
raw = [pair[0] for pair in headers]
|
|
673
|
+
else:
|
|
674
|
+
return None
|
|
675
|
+
names: list[str] = []
|
|
676
|
+
for name in raw:
|
|
677
|
+
if isinstance(name, bytes):
|
|
678
|
+
name = name.decode("latin-1")
|
|
679
|
+
if not isinstance(name, str):
|
|
680
|
+
return None
|
|
681
|
+
names.append(name)
|
|
682
|
+
return names
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _refuse_reserved_headers(
|
|
686
|
+
descriptor: ProviderDescriptor, where: str, headers: Any
|
|
687
|
+
) -> None:
|
|
688
|
+
"""Refuse a header that picks the credential, or forges the ACT token.
|
|
689
|
+
|
|
690
|
+
A value that removes such a header (``omit``) is refused too: taking
|
|
691
|
+
``X-Api-Key`` off a call changes the key it goes out on as surely as
|
|
692
|
+
replacing it.
|
|
693
|
+
"""
|
|
694
|
+
if is_not_given(headers):
|
|
695
|
+
return
|
|
696
|
+
names = header_names(headers)
|
|
697
|
+
if names is None:
|
|
698
|
+
raise UnenforceableOptionError(
|
|
699
|
+
f"`{where}` is not a header map the SDK can read, so it cannot check "
|
|
700
|
+
"that no header in it chooses the credential or carries a consent "
|
|
701
|
+
"token. The AI call was not made. Pass a dict of header names to "
|
|
702
|
+
"values."
|
|
703
|
+
)
|
|
704
|
+
reserved = [name for name in names if descriptor.is_reserved_header(name)]
|
|
705
|
+
if not reserved:
|
|
706
|
+
return
|
|
707
|
+
token_header = descriptor.token_header.lower()
|
|
708
|
+
token = [name for name in reserved if name.lower() == token_header]
|
|
709
|
+
what = (
|
|
710
|
+
"carries the ACT token, which only the SDK may set"
|
|
711
|
+
if token
|
|
712
|
+
else "chooses the credential, account or project the call goes out on"
|
|
713
|
+
)
|
|
714
|
+
raise UnenforceableOptionError(
|
|
715
|
+
f"`{where}` sets `{(token or reserved)[0]}`, a header that {what}. "
|
|
716
|
+
"Changing it on one call would send the request on terms the proof does "
|
|
717
|
+
"not describe, and credential routing could no longer hold. The AI call "
|
|
718
|
+
"was not made and ID Side was not asked. Set credentials when you build "
|
|
719
|
+
"the client, and route between several with consent.wrap(credentials=…)."
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# ── google-genai ────────────────────────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _snake(name: str) -> str:
|
|
727
|
+
"""``httpOptions`` → ``http_options``: google-genai accepts both spellings."""
|
|
728
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def mapping_view(value: Any) -> dict[str, Any] | None:
|
|
732
|
+
"""The dict google-genai will be handed for ``value``, or None.
|
|
733
|
+
|
|
734
|
+
The same reading ``GeminiAdapter.apply`` makes: a dict as it is, a Pydantic
|
|
735
|
+
model as its ``model_dump(exclude_none=True)``. Inspecting anything else
|
|
736
|
+
would inspect something other than what is sent.
|
|
737
|
+
"""
|
|
738
|
+
if isinstance(value, dict):
|
|
739
|
+
return value
|
|
740
|
+
dump = getattr(value, "model_dump", None)
|
|
741
|
+
if callable(dump):
|
|
742
|
+
dumped = dump(exclude_none=True)
|
|
743
|
+
if isinstance(dumped, dict):
|
|
744
|
+
return dumped
|
|
745
|
+
return None
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _refuse_genai_config(descriptor: ProviderDescriptor, config: Any) -> None:
|
|
749
|
+
view = mapping_view(config)
|
|
750
|
+
if view is None:
|
|
751
|
+
return # no config, or one `apply()` refuses on its own
|
|
752
|
+
for raw_name, value in view.items():
|
|
753
|
+
name = _snake(raw_name) if isinstance(raw_name, str) else raw_name
|
|
754
|
+
if name == "tools":
|
|
755
|
+
_refuse_local_tools(value)
|
|
756
|
+
elif name == "http_options" and not is_not_given(value):
|
|
757
|
+
_refuse_http_options(descriptor, "config.http_options", value)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _refuse_http_options(
|
|
761
|
+
descriptor: ProviderDescriptor, prefix: str, value: Any
|
|
762
|
+
) -> None:
|
|
763
|
+
http_options = mapping_view(value)
|
|
764
|
+
if http_options is None:
|
|
765
|
+
raise UnenforceableOptionError(
|
|
766
|
+
f"`{prefix}` is neither a dict nor an HttpOptions, so the SDK cannot "
|
|
767
|
+
"check where it sends the request. The AI call was not made."
|
|
768
|
+
)
|
|
769
|
+
for raw_name, option in http_options.items():
|
|
770
|
+
name = _snake(raw_name) if isinstance(raw_name, str) else str(raw_name)
|
|
771
|
+
where = f"{prefix}.{name}"
|
|
772
|
+
if name == "headers":
|
|
773
|
+
_refuse_reserved_headers(descriptor, where, option)
|
|
774
|
+
elif name not in GENAI_HTTP_PERMITTED and not is_not_given(option):
|
|
775
|
+
_refuse_option(where, name)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def refuse_declared_rewrites(client: Any, descriptor: ProviderDescriptor) -> None:
|
|
779
|
+
"""Refuse a client BUILT to rewrite what the SDK authorizes.
|
|
780
|
+
|
|
781
|
+
Not code — data given to the client at construction, which the library then
|
|
782
|
+
applies to every request below the guard. ``genai.Client(http_options=
|
|
783
|
+
{"extra_body": …})`` merges that body into each call, the ACT token going
|
|
784
|
+
out with it; ``http_options={"headers": {"X-Goog-Api-Key": …}}`` sits beside
|
|
785
|
+
the key the library sets itself, as a second one. Both are read off the
|
|
786
|
+
options the client holds, so they are refused at ``wrap()`` rather than
|
|
787
|
+
discovered on the wire.
|
|
788
|
+
|
|
789
|
+
``openai``, ``anthropic`` and ``mistralai`` have no client-level body to
|
|
790
|
+
declare: ``extra_body`` exists there only per request, where it is refused
|
|
791
|
+
per call.
|
|
792
|
+
"""
|
|
793
|
+
if descriptor.client_module != GENAI_MODULE:
|
|
794
|
+
return
|
|
795
|
+
state = getattr(client, "__dict__", None)
|
|
796
|
+
api_client = state.get("_api_client") if isinstance(state, dict) else None
|
|
797
|
+
options = getattr(api_client, "_http_options", None)
|
|
798
|
+
if getattr(options, "extra_body", None):
|
|
799
|
+
raise UnsupportedProviderError(
|
|
800
|
+
"This google-genai client was built with `http_options.extra_body`, "
|
|
801
|
+
"which the library merges into every request body after the SDK has "
|
|
802
|
+
"authorized it — the proof would describe requests that never left "
|
|
803
|
+
"the process. The client was not wrapped: build it without "
|
|
804
|
+
"`extra_body`, and pass parameters as named arguments of each call."
|
|
805
|
+
)
|
|
806
|
+
headers = getattr(options, "headers", None)
|
|
807
|
+
own_key = getattr(api_client, "api_key", None)
|
|
808
|
+
for name, value in (headers if isinstance(headers, Mapping) else {}).items():
|
|
809
|
+
# The one reserved header google-genai writes itself, from `api_key`.
|
|
810
|
+
if name == "x-goog-api-key" and value == own_key:
|
|
811
|
+
continue
|
|
812
|
+
if isinstance(name, str) and descriptor.is_reserved_header(name):
|
|
813
|
+
raise CredentialRoutingError(
|
|
814
|
+
f"This google-genai client was built with `{name}` in "
|
|
815
|
+
"`http_options.headers`, a header that chooses the credential or "
|
|
816
|
+
"carries the consent token: every call would go out with it, "
|
|
817
|
+
"beside or instead of what the SDK and credential routing set. The "
|
|
818
|
+
"client was not wrapped: pass the key as `api_key`, and route "
|
|
819
|
+
"between credentials with consent.wrap(client, credentials={…})."
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _refuse_local_tools(tools: Any) -> None:
|
|
824
|
+
"""Refuse a tool google-genai would run on the model's behalf.
|
|
825
|
+
|
|
826
|
+
A Python callable, or an MCP client session, turns on automatic function
|
|
827
|
+
calling: the library sends the request, runs the function the model asked
|
|
828
|
+
for, and sends again — up to ten times — all under the one ACT token
|
|
829
|
+
authorized for this call. One token proves one request (invariant #4), so
|
|
830
|
+
this is refused whatever the user answered about tools.
|
|
831
|
+
"""
|
|
832
|
+
if not isinstance(tools, (list, tuple)):
|
|
833
|
+
return
|
|
834
|
+
for index, tool in enumerate(tools):
|
|
835
|
+
kind = (
|
|
836
|
+
"an MCP client session"
|
|
837
|
+
if _is_mcp_session(tool)
|
|
838
|
+
else "a Python callable"
|
|
839
|
+
if callable(tool)
|
|
840
|
+
else None
|
|
841
|
+
)
|
|
842
|
+
if kind is None:
|
|
843
|
+
continue
|
|
844
|
+
raise UnenforceableOptionError(
|
|
845
|
+
f"`config.tools[{index}]` is {kind}, which turns on google-genai's "
|
|
846
|
+
"automatic function calling: the library would send several requests "
|
|
847
|
+
"under the single ACT token authorized for this one, and one token "
|
|
848
|
+
"proves one request. The AI call was not made. Declare the function "
|
|
849
|
+
"instead (types.FunctionDeclaration) and run the tool loop yourself: "
|
|
850
|
+
"each turn is then authorized and proven."
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _is_mcp_session(tool: Any) -> bool:
|
|
855
|
+
"""``mcp.ClientSession``, recognised without importing ``mcp``."""
|
|
856
|
+
return any(
|
|
857
|
+
cls.__name__ == "ClientSession" and cls.__module__.split(".")[0] == "mcp"
|
|
858
|
+
for cls in type(tool).__mro__
|
|
859
|
+
)
|