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/exceptions.py ADDED
@@ -0,0 +1,481 @@
1
+ # Patents: https://sdk.idside.eu/patents
2
+ # This software may be covered by the patents and applications listed there.
3
+
4
+ """Exception hierarchy for the ID Side SDK.
5
+
6
+ Every error below inherits :class:`IDSideError`, with one deliberate exception:
7
+ :class:`UnenforcedCallError` derives from ``BaseException`` so a provider
8
+ library's blanket ``except Exception:`` cannot swallow a security stop. None of
9
+ them is ever a signal to call the AI provider anyway: the SDK is fail-closed, so
10
+ when authorization did not succeed, the AI call simply does not happen.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+
16
+ class IDSideError(Exception):
17
+ """Base class for all ID Side SDK errors."""
18
+
19
+
20
+ class ConsentUnreachableError(IDSideError):
21
+ """Consent could not be verified: cloud unreachable or answer unusable.
22
+
23
+ Raised on network failures, timeouts, 5xx responses, undetermined 4xx, and
24
+ on 200 responses whose body is malformed (the SDK never guesses a
25
+ decision). In line with the *fail-closed* principle, the SDK then makes NO
26
+ call to the AI provider. What to do: treat it as a transient outage —
27
+ retry later or degrade your product flow; if it persists, check your
28
+ network path to sdk.idside.eu.
29
+ """
30
+
31
+
32
+ class ConsentDeniedError(IDSideError):
33
+ """The requested AI usage was denied (HTTP 403).
34
+
35
+ ``reason`` distinguishes the deny motive when the cloud provides one:
36
+
37
+ - ``"fraud_suspended"`` — this app is suspended by fraud detection; contact
38
+ ID Side support through the dashboard.
39
+ - ``"organization_suspended"`` — the whole account behind the app is
40
+ stopped. Billing or compliance, not your app: it is not the same person
41
+ you talk to, which is why it is not the same reason.
42
+ - ``"policy_hold"`` — when the organization's policy cannot apply to a
43
+ call (a provider with no such setting, a provider the organization has
44
+ not allowed, an employee who is not identified), Observe lets the call
45
+ through and flags it, Enforce holds it; this is Enforce's hold. No option
46
+ in your code lets a held call out: the provider, endpoint, model and
47
+ identifier the call names, and the credentials you label, are your
48
+ code's to change, the policy is its owner's.
49
+ - ``None`` — a 403 whose reason this SDK does not qualify. Today that is a
50
+ secret key minted without the ``authorize`` scope; the message carries
51
+ the cloud's detail, which names the missing scope.
52
+
53
+ A user's own refusal never arrives here, and neither does their silence:
54
+ both resolve to an authorization that SUCCEEDS and carries the refusals —
55
+ forced provider parameters, :class:`ToolUseRefusedError` where the only
56
+ lever is the call itself, and :class:`MissingCredentialError` where the
57
+ refusal names a credential posture nobody provisioned.
58
+
59
+ Either way, do not retry, and do not route around it: surface the denial in
60
+ your product flow.
61
+ """
62
+
63
+ def __init__(self, message: str, *, reason: str | None = None) -> None:
64
+ super().__init__(message)
65
+ self.reason = reason
66
+
67
+
68
+ class ToolUseRefusedError(IDSideError):
69
+ """The call carries agentic tools, and this user has not accepted them.
70
+
71
+ Not an outage and not a bug: it is the user's answer — or the silence that
72
+ counts as one — arriving at the one place it can be applied. Every
73
+ provider's zero-retention arrangement excludes agentic features while the
74
+ API itself does not block them, so a call that hands the model tools falls
75
+ outside the retention regime the user was promised — and the SDK is the only
76
+ component that can see it happening, because it is the only one that sees
77
+ the outgoing request.
78
+
79
+ Three situations raise it. The user refused agentic tools, or nobody ever
80
+ put the question to them: what nobody accepted is refused, so an option a
81
+ person never answered is ``False`` before anything is enforced or signed. An
82
+ integration that passes tools and runs no Preference Center therefore raises
83
+ this on every call, for every user — the mechanism asking to be asked, not a
84
+ misconfiguration to work around. Or an organization policy refuses them,
85
+ which no answer in the widget can lift: a policy only tightens.
86
+
87
+ Refusing rather than silently dropping the tools is deliberate: an agent
88
+ stripped of its tools does not fail, it answers wrongly, and the developer
89
+ would have no way to know. What to do: retry without the tools, or route the
90
+ user to a non-agentic path; for a visitor nobody asked, run the widget so
91
+ there is an answer to get. Never retry the same call, it will be refused
92
+ again. The message says the same whoever refused: the SDK cannot tell.
93
+ """
94
+
95
+
96
+ class UnenforceableOptionError(IDSideError):
97
+ """The call passes an option that would undo what the SDK enforces on it.
98
+
99
+ The SDK authorizes a request, forces the parameters the user's answer
100
+ requires, and the cloud signs a proof describing that request. Some options
101
+ of the provider libraries act *after* all of that: ``extra_body`` rewrites
102
+ the body (``store``, ``tools``, even ``model``), ``extra_query`` and
103
+ ``server_url`` or ``http_options.base_url`` change where the request goes, a
104
+ header such as ``Authorization`` or ``X-Api-Key`` changes the credential it
105
+ goes out on. Any of them makes the request that leaves the process a
106
+ different one from the request that was proven — and nothing would record
107
+ the difference. So they are refused, before the cloud is asked: no
108
+ authorization, no ledger entry, no AI call.
109
+
110
+ The list is closed. An option is let through only if it is known to change
111
+ nothing a proof covers — a timeout, a retry policy, a header that selects no
112
+ credential — so an option a provider library adds tomorrow is refused until
113
+ someone has looked at it. Google's automatic function calling is refused
114
+ for a related reason: it would send several requests under a single ACT
115
+ token.
116
+
117
+ The message names the option or the header, never its value, which may be a
118
+ key. What to do: pass the parameter as a named argument of the call, where
119
+ it is part of the request that is authorized and hashed; set credentials
120
+ and the destination on the client when you build it, or through
121
+ ``consent.wrap(credentials=…)`` for routing; and for Gemini, declare
122
+ functions and run the tool loop yourself, so each turn is authorized and
123
+ proven.
124
+ """
125
+
126
+
127
+ class CredentialRoutingError(IDSideError):
128
+ """Credential routing is configured in a way the SDK cannot honour.
129
+
130
+ Routing is how an account-level privacy setting becomes a per-user one: no
131
+ provider exposes training opt-out as a request parameter, but the setting
132
+ lives on the credential (a project at OpenAI and Gemini, a workspace at
133
+ Anthropic, an organisation at Mistral), so a developer who provisions one
134
+ credential per posture lets the SDK send each call on the one matching that
135
+ user's answer.
136
+
137
+ This error means the ``credentials=`` mapping given to ``consent.wrap()``
138
+ cannot do that: the provider declares no routing at all, a bare identifier
139
+ was given where only a second client will do, an identifier is not of the
140
+ shape the provider issues, or an alternative client does not expose the call
141
+ site being wrapped. All of them are configuration mistakes, raised at
142
+ ``wrap()`` time where they can still be fixed — except the last resort check
143
+ that a routed credential really reached the outgoing request, which fails
144
+ the call rather than let it leave on the wrong one.
145
+ """
146
+
147
+
148
+ class MissingCredentialError(CredentialRoutingError):
149
+ """This user's answer needs a credential the developer has not provisioned.
150
+
151
+ The cloud resolved the user's preferences and named the posture this call
152
+ must go out on; nothing in ``credentials=`` carries that posture. The SDK
153
+ refuses the call rather than send it on the credential it happens to hold,
154
+ which would quietly place the user under a privacy regime they declined —
155
+ and leave no trace that it happened.
156
+
157
+ ``posture`` is what was required, ``provider`` who for, and ``provisioned``
158
+ the labels that do exist. What to do: provision a credential for that
159
+ posture (the message names the unit the provider uses) and add it to
160
+ ``credentials=`` under that exact label. Never a retry: the answer will be
161
+ the same until the configuration changes.
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ message: str,
167
+ *,
168
+ posture: str,
169
+ provider: str,
170
+ provisioned: tuple[str, ...] = (),
171
+ ) -> None:
172
+ super().__init__(message)
173
+ self.posture = posture
174
+ self.provider = provider
175
+ self.provisioned = provisioned
176
+
177
+
178
+ class AuthenticationError(IDSideError):
179
+ """The ID Side API rejected the credentials (HTTP 401).
180
+
181
+ A configuration problem, not an outage. What to do: check the ``api_key``
182
+ passed to ``consent.wrap()`` (or your key management) against the keys
183
+ listed in the ID Side dashboard; make sure you are not sending a test key
184
+ against production or vice versa. Retrying without fixing the key is
185
+ pointless.
186
+
187
+ Two 401s are about the visitor rather than the key, and raise a
188
+ :class:`BindingError` subclass instead: :class:`InvalidBindingError` and
189
+ :class:`BindingRequiredError`.
190
+ """
191
+
192
+
193
+ class BindingError(AuthenticationError):
194
+ """The cloud refused how this call named its visitor (HTTP 401), not the key.
195
+
196
+ The secret key was accepted; the User Binding Token (UBT) was not, or there
197
+ was none where the application requires one. The cure is the widget, never
198
+ the key, and never a fallback to ``user_id``: catch this class before
199
+ :class:`AuthenticationError` so a binding problem does not page whoever
200
+ owns the keys. It stays an :class:`AuthenticationError`, so a handler
201
+ written before it existed still catches it.
202
+ """
203
+
204
+
205
+ class InvalidBindingError(BindingError):
206
+ """The visitor's UBT no longer verifies (HTTP 401, ``ubt_invalid``).
207
+
208
+ It expired — a UBT lives 24 hours, so every visitor who comes back the next
209
+ day with a server session still open produces this — or it was issued for
210
+ another application, was altered, or is not a UBT at all. What to do:
211
+ discard the UBT stored in this visitor's session, stop the AI feature, and
212
+ let the widget issue a new one (``IDSide.showPreferences()`` then *Confirm
213
+ these choices* renews it without changing any choice). Retrying with the
214
+ same token cannot succeed.
215
+ """
216
+
217
+
218
+ class BindingRequiredError(BindingError):
219
+ """The application requires a UBT; the call had none (401, ``ubt_required``).
220
+
221
+ The application is set to accept only widget-bound identities, and the call
222
+ named its user with ``user_id``. What to do: have the visitor go through the
223
+ widget before any AI call, store the UBT it issues in the server session, and
224
+ pass it as ``ubt``. ``user_id`` is not a fallback for a strict application.
225
+ """
226
+
227
+
228
+ class ConsentRefreshRequiredError(IDSideError):
229
+ """The visitor must review their choices before this call (HTTP 409).
230
+
231
+ Raised on ``consent_refresh_required``: the visitor's UBT still verifies,
232
+ but the choices it is bound to no longer cover what this application now
233
+ requires — the policy or the required questions changed since they
234
+ answered, typically because a provider was added. Not an outage, and
235
+ deliberately not a :class:`ConsentUnreachableError`: retrying later
236
+ changes nothing. What to do: stop this AI request and show the visitor the
237
+ widget again; the call can be made once they have recorded their current
238
+ choices. The cloud's detail, appended to the message, says when the widget
239
+ cannot help (a provider ID Side holds no questions for).
240
+ """
241
+
242
+
243
+ class QuotaExceededError(IDSideError):
244
+ """The app's ID Side plan quota is exhausted (HTTP 402).
245
+
246
+ There is never a silent degradation: AI calls stop until quota is
247
+ available again. What to do: upgrade the plan or wait for the quota window
248
+ to reset (see the usage page in the ID Side dashboard).
249
+ """
250
+
251
+
252
+ class RateLimitedError(IDSideError):
253
+ """Too many authorize requests in a short window (HTTP 429).
254
+
255
+ What to do: slow down and retry after a backoff. If you hit this in
256
+ normal operation, review your call patterns or ask for a higher rate
257
+ limit via the dashboard.
258
+ """
259
+
260
+
261
+ class ContractError(IDSideError):
262
+ """The cloud rejected the request payload as invalid (HTTP 422).
263
+
264
+ This indicates an SDK/cloud contract mismatch — most likely an SDK bug or
265
+ a badly outdated SDK, never something end users can trigger. What to do:
266
+ upgrade the ``idside`` package; if the error persists, report it at
267
+ https://github.com/idside-eu/idside-sdk/issues with the error message.
268
+ """
269
+
270
+
271
+ class ConsentTokenError(IDSideError):
272
+ """An ACT token or its request binding is invalid.
273
+
274
+ Raised when a token is invalid, expired, replayed, or malformed — and when
275
+ a deterministic ``request_hash`` cannot be computed from the call payload
276
+ (e.g. a value is not JSON-serializable). What to do: for hashing errors,
277
+ pass JSON-compatible values or Pydantic models (``model_dump()``) in the
278
+ provider call; the error message names the offending parameter.
279
+ """
280
+
281
+
282
+ class ActVerificationError(ConsentTokenError):
283
+ """Base class for failures of local ACT verification (:func:`idside.verify_act`).
284
+
285
+ Every subclass means the same operational thing: **the proof did not check
286
+ out**, so it must not be accepted as evidence that consent was verified.
287
+ The subclasses exist so an auditor can tell a forgery attempt from an
288
+ expired token or an outdated key set. Catching this class alone is enough
289
+ to fail closed; catch the subclasses when the reason drives the response.
290
+ """
291
+
292
+
293
+ class ActMalformedError(ActVerificationError):
294
+ """The token is not a well-formed ACT: bad JWS structure, or bad claims.
295
+
296
+ Covers a value that is not a three-part JWS, base64url that does not decode
297
+ or is not canonical (a segment carrying unused trailing bits, which a
298
+ compliant encoder never produces), JSON that does not parse, a header
299
+ without ``kid``, a missing mandatory claim and a claim of the wrong type.
300
+ What to do: check you passed the raw ``X-AI-Consent-Token`` header value,
301
+ untruncated and unmodified.
302
+ """
303
+
304
+
305
+ class ActAlgorithmError(ActVerificationError):
306
+ """The token's ``alg`` header is not ``ES256``.
307
+
308
+ ACT verification pins the algorithm: ``none``, ``HS256`` and every other
309
+ value are rejected outright, never negotiated. A token arriving with
310
+ another algorithm is an algorithm-confusion forgery attempt, not a
311
+ compatibility issue. What to do: do not accept the token; treat repeated
312
+ occurrences as an attack signal worth logging.
313
+ """
314
+
315
+
316
+ class ActTypeMismatchError(ActVerificationError):
317
+ """The token's ``typ`` header is not ``act+jwt``.
318
+
319
+ ID Side signs several token types with the same key material — a User
320
+ Binding Token is ``ubt+jwt``. Accepting one where an ACT is expected would
321
+ let a token minted for another purpose stand in as a proof of consent
322
+ (token substitution). What to do: pass the ACT token, not the UBT.
323
+ """
324
+
325
+
326
+ class ActKeyNotFoundError(ActVerificationError):
327
+ """No usable public key matches the token's ``kid`` in the supplied JWKS.
328
+
329
+ Also raised when the ``kid`` matches an entry that cannot verify an ES256
330
+ signature (wrong key type, wrong curve, encryption-only, unusable
331
+ coordinates). Verification is fail-closed: no other key is tried. What to
332
+ do: refresh the JWKS from ``/.well-known/jwks.json`` — the signing key may
333
+ have been rotated since your copy was taken.
334
+ """
335
+
336
+
337
+ class ActPrivateKeyInKeySetError(ActVerificationError):
338
+ """The key set contains **private** key material (``d``, ``k``).
339
+
340
+ Deliberately **not** a subclass of :class:`ActKeyNotFoundError`, even though
341
+ both are about the key set, because the two call for opposite responses. A
342
+ key that is merely missing says *"refresh the JWKS, it may have rotated"*,
343
+ and ``except ActKeyNotFoundError: refresh_and_retry()`` is the natural
344
+ handler for it. Against a key set carrying a private key, that handler would
345
+ loop — hammering the JWKS through the exact incident it should be escalating.
346
+ The class is the contract a caller writes against; it has to separate the
347
+ two.
348
+
349
+ A key set is public by definition, so private material in one means either a
350
+ signing key was published by mistake or the key set was poisoned. Both are
351
+ incidents, and verification is the moment where they can still be caught.
352
+ What to do: do **not** retry. Remove the private members from the JWKS and
353
+ rotate the key — assume anything that key signed can be forged.
354
+ """
355
+
356
+
357
+ class ActSignatureError(ActVerificationError):
358
+ """The ES256 signature does not match the token's header and payload.
359
+
360
+ The token was altered after signing, or was never signed by the key it
361
+ claims. What to do: reject it. This is not a transient condition and must
362
+ never be retried into acceptance.
363
+ """
364
+
365
+
366
+ class ActExpiredError(ActVerificationError):
367
+ """The token is past its ``exp`` (plus the allowed clock leeway).
368
+
369
+ ACT tokens live one hour: an expired one proves nothing about a call made
370
+ now. What to do: for a live call, obtain a fresh token (the SDK does this
371
+ per request). For an audit of a past call, this is expected — verify the
372
+ signature and the ledger entry instead of the time window.
373
+ """
374
+
375
+
376
+ class ActNotYetValidError(ActVerificationError):
377
+ """The token is not valid yet: ``iat`` or ``nbf`` is in the future.
378
+
379
+ Usually a clock skew between the verifier and the ID Side cloud beyond the
380
+ allowed leeway. What to do: check the verifying machine's clock (NTP)
381
+ before suspecting the token.
382
+ """
383
+
384
+
385
+ class ActIssuerError(ActVerificationError):
386
+ """The token's ``iss`` is not an accepted issuer.
387
+
388
+ Production proofs are issued by ``idside.eu``. Development tokens
389
+ (``test.idside.eu``) are rejected unless the caller explicitly opts in with
390
+ ``allow_test_issuer=True`` — a dev token must never silently pass as a real
391
+ one. What to do: verify against the environment you meant to audit.
392
+ """
393
+
394
+
395
+ class ActBindingMismatchError(ActVerificationError):
396
+ """A claim the caller pinned does not match the token.
397
+
398
+ **One token = one request**: a token whose ``request_hash`` does not match
399
+ the request it is attached to is invalid, however well it is signed. The
400
+ same applies to a pinned ``nonce``, ``sub`` or ``app_id``. What to do:
401
+ reject the proof — it belongs to a different call, user or application.
402
+ """
403
+
404
+
405
+ class ActVerificationUnavailableError(ActVerificationError):
406
+ """Verification could not be performed, so the token is not accepted.
407
+
408
+ Raised when the optional ES256 backend is missing (install
409
+ ``idside[verify]``), when the JWKS URL cannot be fetched or returns
410
+ something unusable, and when the JWKS is ambiguous (two keys sharing a
411
+ ``kid``). Fail-closed: "could not check" is never "valid". What to do: fix
412
+ the environment or the key source, then verify again.
413
+ """
414
+
415
+
416
+ class MissingIdentityError(IDSideError):
417
+ """No ``user_id`` and no ``ubt`` was provided for a consent-enforced AI call.
418
+
419
+ What to do: pass exactly one of ``user_id="usr_..."`` or ``ubt="eyJ..."``
420
+ on every AI call made through a wrapped client — the SDK consumes it and
421
+ never forwards it to the provider. Use ``ubt`` when your frontend runs the
422
+ ID Side widget (strongest identity binding); use ``user_id`` for
423
+ backend-only integrations without the widget.
424
+ """
425
+
426
+
427
+ class ConflictingIdentityError(IDSideError):
428
+ """Both ``user_id`` and ``ubt`` were provided for the same AI call.
429
+
430
+ The two identity forms are exclusive: the User Binding Token already names
431
+ the user (it is a JWT signed by ID Side, bound to user, app, session and
432
+ consent state), so a separate ``user_id`` alongside it is at best redundant
433
+ and at worst a spoofing attempt the cloud would have to arbitrate. What to
434
+ do: pass ``ubt`` alone when you have the widget, ``user_id`` alone when
435
+ you do not.
436
+ """
437
+
438
+
439
+ class UnenforcedCallError(BaseException):
440
+ """A request was about to leave a wrapped client without consent enforcement.
441
+
442
+ The SDK patches the endpoints its provider descriptor declares, and that can
443
+ never be exhaustive: a provider ships a new endpoint, a helper posts to the
444
+ API directly, a resource nobody has wrapped yet (``embeddings``, ``images``,
445
+ ``audio``, ``moderations``, ``batches``, ``files``, ``beta.*``) is reachable
446
+ from the same client. Every one of those would otherwise reach the provider
447
+ with no authorize, no ACT token and no ledger entry — silently, which is the
448
+ only failure mode this product cannot have. A guard on the client's HTTP
449
+ transport catches them and refuses the request instead.
450
+
451
+ The guard is a detector, not an enforcer, and it is deliberately narrow: it
452
+ reads the HTTP method, URL path and ACT header. The header must match private
453
+ authorization provenance for this wrapped client and route. It never reads
454
+ the body, query string or another header, and hashes, logs and sends nothing.
455
+ ``GET``,
456
+ ``HEAD``, ``OPTIONS`` and ``DELETE`` are allowed through: they carry no
457
+ prompt to infer on and are the management surface (``models.list``,
458
+ ``files.delete``, polling a response id).
459
+
460
+ What to do: an endpoint that carries no inference does not belong on a
461
+ consent-enforced client. Build a **second, unwrapped client** for that
462
+ traffic. There is no bypass flag on the wrapped one and there will not be:
463
+ it would be the *opaque tunnel* invariant defeated through the back door.
464
+ If the endpoint *is* an inference endpoint, it needs a call site in the
465
+ provider descriptor — please report it.
466
+
467
+ **Deliberately not an** :class:`IDSideError`, and not even an ``Exception``.
468
+ ``openai._base_client`` wraps any ``Exception`` raised while sending into an
469
+ ``APIConnectionError`` *and retries it*, which would turn a consent refusal
470
+ into a misleading, repeatedly retried network error. A blanket
471
+ ``except Exception:`` in a provider library must not be able to swallow a
472
+ security stop, so catch this class by name.
473
+ """
474
+
475
+
476
+ class UnsupportedProviderError(IDSideError):
477
+ """No ID Side adapter is registered for the given provider client.
478
+
479
+ What to do: check the provider is supported (or OpenAI-compatible), or
480
+ register a custom adapter with ``idside.adapters.register_adapter()``.
481
+ """