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,473 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: idside
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: ID Side — AI Consent Infrastructure SDK (opaque tunnel, ACT token, ledger).
|
|
5
|
+
Project-URL: Homepage, https://sdk.idside.eu
|
|
6
|
+
Project-URL: Repository, https://github.com/idside-eu/idside-sdk
|
|
7
|
+
Project-URL: Documentation, https://github.com/idside-eu/idside-sdk/tree/main/docs
|
|
8
|
+
Project-URL: License, https://sdk.idside.eu/legal/sdk-licence
|
|
9
|
+
Project-URL: Patents, https://sdk.idside.eu/patents
|
|
10
|
+
Author-email: ID Side <dev@idside.eu>
|
|
11
|
+
License-Expression: LicenseRef-IDSide-Source-Available
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
License-File: NOTICE
|
|
14
|
+
Keywords: act-token,ai,compliance,consent,gdpr,privacy
|
|
15
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: Other/Proprietary License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: anthropic>=0.39; extra == 'all'
|
|
29
|
+
Requires-Dist: cryptography>=50; extra == 'all'
|
|
30
|
+
Requires-Dist: google-genai>=1.0; extra == 'all'
|
|
31
|
+
Requires-Dist: mistralai>=1.5; extra == 'all'
|
|
32
|
+
Requires-Dist: openai>=1.0; extra == 'all'
|
|
33
|
+
Provides-Extra: anthropic
|
|
34
|
+
Requires-Dist: anthropic>=0.39; extra == 'anthropic'
|
|
35
|
+
Provides-Extra: gemini
|
|
36
|
+
Requires-Dist: google-genai>=1.0; extra == 'gemini'
|
|
37
|
+
Provides-Extra: mistral
|
|
38
|
+
Requires-Dist: mistralai>=1.5; extra == 'mistral'
|
|
39
|
+
Provides-Extra: openai
|
|
40
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
41
|
+
Provides-Extra: verify
|
|
42
|
+
Requires-Dist: cryptography>=50; extra == 'verify'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# idside — AI Consent Infrastructure (Python SDK)
|
|
46
|
+
|
|
47
|
+
A signed proof of the AI consent each call was authorized against — for every AI
|
|
48
|
+
call your backend makes.
|
|
49
|
+
|
|
50
|
+
> ⚠️ **Pre-release (`0.1.0.dev0`).** Install with `pip install 'idside[openai]'`.
|
|
51
|
+
> End-to-end use needs an ID Side account: sign up at
|
|
52
|
+
> <https://sdk.idside.eu/dashboard/signup>. The free plan covers 10,000 calls a
|
|
53
|
+
> month.
|
|
54
|
+
|
|
55
|
+
## Quickstart
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from openai import OpenAI
|
|
59
|
+
from idside import consent
|
|
60
|
+
|
|
61
|
+
client = consent.wrap(OpenAI()) # 1. wrap your provider client
|
|
62
|
+
|
|
63
|
+
client.chat.completions.create( # 2. call it as usual…
|
|
64
|
+
model="gpt-4o",
|
|
65
|
+
messages=[{"role": "user", "content": "..."}],
|
|
66
|
+
user_id="usr_marie_456", # 3. …naming the end user
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
On every call, the SDK asks the ID Side cloud to authorize the request, applies
|
|
71
|
+
the provider parameters the cloud decided (e.g. OpenAI `store=false`), attaches
|
|
72
|
+
a signed **ACT token** (an ECDSA JWT bound to this exact request — the audit
|
|
73
|
+
proof), and only then calls the provider. The cloud has already appended the
|
|
74
|
+
matching entry to an immutable ledger. You never see, store or relay the user's
|
|
75
|
+
consent preferences — that is the point.
|
|
76
|
+
|
|
77
|
+
**Fail-closed:** if consent cannot be verified — cloud unreachable, malformed
|
|
78
|
+
answer — the AI call **does not happen**. The SDK never guesses a decision.
|
|
79
|
+
|
|
80
|
+
## Supported providers
|
|
81
|
+
|
|
82
|
+
| Provider | Client library | Wrapped call sites | Token carried via |
|
|
83
|
+
| -------- | -------------- | ------------------ | ----------------- |
|
|
84
|
+
| OpenAI | `openai` | `chat.completions.create`, `responses.create` (+ `parse`) | `extra_headers` |
|
|
85
|
+
| Anthropic | `anthropic` | `messages.create` (+ `parse`, `stream`) | `extra_headers` |
|
|
86
|
+
| Google Gemini | `google-genai` | `models.generate_content(_stream)` | `config.http_options.headers` |
|
|
87
|
+
| Mistral | `mistralai` | `chat/fim/agents` `complete` & `stream` | `http_headers` |
|
|
88
|
+
|
|
89
|
+
OpenAI-compatible providers (Groq, Together, xAI…) work through the `openai`
|
|
90
|
+
client and its descriptor. DeepSeek is the exception: an `OpenAI` client pointed
|
|
91
|
+
at `api.deepseek.com` gets the `deepseek` descriptor, so its proofs say
|
|
92
|
+
`provider: "deepseek"` and it takes no `credentials=` (see `docs/providers.md`). Provider libraries are optional extras:
|
|
93
|
+
`pip install idside[openai]`, `[anthropic]`, `[gemini]`, `[mistral]` or `[all]`.
|
|
94
|
+
|
|
95
|
+
`client.with_options(...)` and `client.copy(...)` hand back an enforced client,
|
|
96
|
+
recursively, and so do `with_raw_response.*` and `with_streaming_response.*` on
|
|
97
|
+
a declared call site: those wrappers are built on first access, after the patch,
|
|
98
|
+
so they wrap the enforced method rather than bypassing it. Everything *else* on
|
|
99
|
+
a wrapped client — `embeddings`, `images`, `audio`, `moderations`, `batches`,
|
|
100
|
+
file uploads, `beta.*` — is **refused rather than sent**: a guard on the client's
|
|
101
|
+
`httpx` transport raises `UnenforcedCallError` for any body-bearing request that
|
|
102
|
+
did not go through enforcement, while `GET`/`HEAD`/`OPTIONS`/`DELETE` (the
|
|
103
|
+
management surface) pass through. Use a second, **unwrapped** client for that
|
|
104
|
+
traffic; there is no bypass option on a wrapped one. `UnenforcedCallError`
|
|
105
|
+
derives from `BaseException`, so a provider library's blanket `except
|
|
106
|
+
Exception:` cannot turn a consent stop into a retried connection error — catch
|
|
107
|
+
it by name. The guard also holds each authorized request to the origin the
|
|
108
|
+
client had when it was wrapped (so `client.base_url = …` afterwards is refused),
|
|
109
|
+
and never lets it follow a redirect: a 3xx comes back as the provider's answer.
|
|
110
|
+
A provider client the guard cannot cover — no HTTP transport found, Vertex AI
|
|
111
|
+
over mTLS, `google-genai` sending its async half on `aiohttp`, or a transport
|
|
112
|
+
whose `send` will not take `follow_redirects` — is not wrapped at all: `wrap()`
|
|
113
|
+
raises `UnsupportedProviderError` and says what to pass instead. That last one
|
|
114
|
+
is checked at `wrap()` rather than discovered later on purpose: refusing the
|
|
115
|
+
keyword mid-call would fail the request *after* ID Side had authorized it and
|
|
116
|
+
recorded the proof, while every bodyless call kept working.
|
|
117
|
+
|
|
118
|
+
## What a single call may not do
|
|
119
|
+
|
|
120
|
+
The proof describes the request the SDK authorized, so an option that would
|
|
121
|
+
change that request *after* authorization is refused with
|
|
122
|
+
`UnenforceableOptionError` — before ID Side is asked, with nothing sent:
|
|
123
|
+
|
|
124
|
+
- a body or query rewrite: `extra_body`, `extra_query`,
|
|
125
|
+
`config.http_options.extra_body`;
|
|
126
|
+
- a per-call destination: `server_url`, `config.http_options.base_url`,
|
|
127
|
+
`api_version`, `client_args`, `httpx_client` and the like;
|
|
128
|
+
- a header that picks the credential or carries the ACT token —
|
|
129
|
+
`Authorization`, `X-Api-Key`, `x-goog-api-key`, `OpenAI-Project`,
|
|
130
|
+
`X-AI-Consent-Token`… in any casing, including an `omit` that removes one;
|
|
131
|
+
- a Gemini tool the library would call itself (a Python function, an MCP
|
|
132
|
+
session): automatic function calling would send several requests under one
|
|
133
|
+
token. Declare the function and run the loop yourself.
|
|
134
|
+
|
|
135
|
+
The list is closed: only options known to change nothing a proof covers pass —
|
|
136
|
+
`timeout`, `timeout_ms`, `retries`, `http_options.timeout` and `retry_options`,
|
|
137
|
+
the `parse`/`stream` helper arguments, and header maps holding no reserved
|
|
138
|
+
header (`X-Request-Id`, `anthropic-beta`…). An option a newer library adds is
|
|
139
|
+
refused until it has been classified — including on the day that library *moves*
|
|
140
|
+
the schema the SDK tells options from parameters by: the fallback is then the
|
|
141
|
+
schema as this release read it, never a wider list. Request parameters are
|
|
142
|
+
unaffected: pass them as named arguments and they are hashed, and forced where
|
|
143
|
+
the user's answer requires it.
|
|
144
|
+
|
|
145
|
+
Deriving a client is a closed list too, checked before anything is sent.
|
|
146
|
+
`with_options()`, `copy()` and `with_middleware()` pass `timeout`, `max_retries`
|
|
147
|
+
and the webhook secrets; `base_url` passes only if the origin is unchanged,
|
|
148
|
+
`default_headers`/`set_default_headers` only if no reserved header is added,
|
|
149
|
+
replaced or dropped, and `middleware` only if it is the middleware the client
|
|
150
|
+
already runs — so `with_middleware()` given none, which changes nothing, still
|
|
151
|
+
derives. A new credential (`api_key`, `auth_token`, `organization`, `project`…)
|
|
152
|
+
raises `CredentialRoutingError`: route with `credentials=` instead. A new
|
|
153
|
+
transport, query or middleware, or any unclassified option, raises
|
|
154
|
+
`UnsupportedProviderError`: build that client and wrap it on its own. A derived
|
|
155
|
+
client must also still be detected as the same provider.
|
|
156
|
+
|
|
157
|
+
What a client was *built* to rewrite is read at `wrap()` too: a
|
|
158
|
+
`genai.Client(http_options={"extra_body": …})` merges that body into every
|
|
159
|
+
request and is refused (`UnsupportedProviderError`), and a reserved header in
|
|
160
|
+
its `http_options.headers` raises `CredentialRoutingError`. For a client library
|
|
161
|
+
this SDK has no classification for, the known offenders (`extra_body`,
|
|
162
|
+
`extra_query`, `server_url`, `base_url`, `http_options` beyond timeouts, retries
|
|
163
|
+
and ordinary headers, reserved headers) are still refused per call.
|
|
164
|
+
|
|
165
|
+
The boundary is the client you hand to `wrap()`: a transport you build yourself
|
|
166
|
+
(`http_client=` with request hooks, a custom `httpx` transport, Mistral SDK
|
|
167
|
+
hooks) runs after the guard and can rewrite what it sends. That is your code,
|
|
168
|
+
outside what a library in your process can police — and the proof records what
|
|
169
|
+
was authorized.
|
|
170
|
+
|
|
171
|
+
## `user_id` vs `ubt`
|
|
172
|
+
|
|
173
|
+
Every wrapped call names the end user with exactly **one** of two kwargs. Both
|
|
174
|
+
are consumed by the SDK and never forwarded to the AI provider:
|
|
175
|
+
|
|
176
|
+
- **`ubt="eyJ..."`** — use this when your frontend runs the ID Side Preference
|
|
177
|
+
Center widget. The widget hands your frontend a **User Binding Token**: a JWT
|
|
178
|
+
signed by ID Side that binds the user, your app, the session and the consent
|
|
179
|
+
state. Forward it to your backend and pass it as-is — the SDK treats it as
|
|
180
|
+
opaque; the cloud verifies it. This defeats identity spoofing (collecting an
|
|
181
|
+
opt-out for one user, then calling the SDK with another), and the proof
|
|
182
|
+
records `binding="ubt"` — the strong link.
|
|
183
|
+
- **`user_id="usr_..."`** — use this for backend-only integrations without the
|
|
184
|
+
widget. You assert the identity yourself; accepted, but the proof records the
|
|
185
|
+
weaker `binding="user_id"`.
|
|
186
|
+
|
|
187
|
+
Passing both raises `ConflictingIdentityError`; passing neither raises
|
|
188
|
+
`MissingIdentityError`. In both cases nothing is sent anywhere.
|
|
189
|
+
|
|
190
|
+
## One credential per posture — `credentials=`
|
|
191
|
+
|
|
192
|
+
No provider lets you switch training off per request: the privacy posture lives
|
|
193
|
+
on the **credential** (a *project* at OpenAI and Gemini, a *workspace* at
|
|
194
|
+
Anthropic, an *organisation* at Mistral). One key therefore means one posture for
|
|
195
|
+
every user of your app at once. Provision one credential per posture and the SDK
|
|
196
|
+
sends each call on the one that user's answer requires — the only route by which
|
|
197
|
+
a training opt-out can be honoured per person.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
client = consent.wrap(
|
|
201
|
+
OpenAI(), # OpenAI validates a per-request
|
|
202
|
+
credentials={ # header, so an id is enough
|
|
203
|
+
"default": "proj_medhelp_default",
|
|
204
|
+
"zero_retention": "proj_medhelp_zdr",
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
claude = consent.wrap(
|
|
209
|
+
Anthropic(), # no such header exists here:
|
|
210
|
+
credentials={ # the key is chosen at build time
|
|
211
|
+
"no_training": Anthropic(api_key=PRIVATE_WORKSPACE_KEY),
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**You label; you do not choose.** Which posture a consent state requires is
|
|
217
|
+
policy — ID Side resolves it and names the label, the SDK matches it. There is no
|
|
218
|
+
callback to override it. A posture you have not provisioned raises
|
|
219
|
+
`MissingCredentialError` and the call is never sent on another credential; a
|
|
220
|
+
mapping the SDK cannot honour raises `CredentialRoutingError` from `wrap()`
|
|
221
|
+
itself. Your credentials never leave the process: ID Side is told which *labels*
|
|
222
|
+
exist, never their values.
|
|
223
|
+
|
|
224
|
+
A client you pass as a credential sends the calls routed to it, so it is held to
|
|
225
|
+
what the wrapped client is held to: `wrap()` guards its HTTP transport, freezes
|
|
226
|
+
the origin it names *itself* — a residency posture may live on another host,
|
|
227
|
+
such as `https://eu.api.openai.com/v1` — and a routed call never follows a
|
|
228
|
+
redirect. A client whose transport cannot be guarded is refused by `wrap()` with
|
|
229
|
+
`UnsupportedProviderError`. It also stops being an unwrapped client: a request
|
|
230
|
+
sent on it directly is refused like one sent on the wrapped client, so keep
|
|
231
|
+
non-inference traffic on a client named nowhere in `wrap()`. The mapping is read
|
|
232
|
+
once: changing it after `wrap()` reroutes nothing, derived clients included.
|
|
233
|
+
|
|
234
|
+
## Typed errors and what to do about them
|
|
235
|
+
|
|
236
|
+
All errors inherit `IDSideError` except `UnenforcedCallError` (see above). None of them ever means "call the provider
|
|
237
|
+
anyway" — when authorization did not succeed, the AI call simply did not happen.
|
|
238
|
+
|
|
239
|
+
| Error | Meaning | What to do |
|
|
240
|
+
| ----- | ------- | ---------- |
|
|
241
|
+
| `ConsentUnreachableError` | Cloud unreachable, 5xx, or unusable answer | Treat as a transient outage: retry later, degrade your flow |
|
|
242
|
+
| `ConsentDeniedError` | Usage denied for the account or key, or held by the organization's policy (`.reason`: `fraud_suspended`, `organization_suspended`, `policy_hold`, or `None` = a denial the SDK cannot qualify, such as a key without the `authorize` scope). Never a user's own refusal | Do not retry; read the message and surface the denial in your product |
|
|
243
|
+
| `AuthenticationError` | Bad ID Side API key (HTTP 401) | Fix the key; retrying is pointless |
|
|
244
|
+
| `InvalidBindingError` | The visitor's UBT did not verify — expired, issued for another app, altered, or not a UBT at all (HTTP 401 `ubt_invalid`); a `BindingError`, so also an `AuthenticationError` | Not the key: discard the stored UBT and let the widget issue a new one |
|
|
245
|
+
| `BindingRequiredError` | The app requires a UBT and the call passed `user_id` (HTTP 401 `ubt_required`); a `BindingError` too | Show the widget before any AI call and pass its UBT as `ubt` |
|
|
246
|
+
| `ConsentRefreshRequiredError` | The visitor's choices no longer cover what the app requires (HTTP 409 `consent_refresh_required`). Not an outage, so not a `ConsentUnreachableError` | Stop the request and show the widget again; never retry with backoff |
|
|
247
|
+
| `QuotaExceededError` | Plan quota exhausted (HTTP 402) | Upgrade or wait for the window to reset |
|
|
248
|
+
| `RateLimitedError` | Too many authorize calls (HTTP 429) | Back off and retry |
|
|
249
|
+
| `ContractError` | The cloud refused the authorize payload (HTTP 422): a value your code passed, or an SDK/cloud mismatch | Fix the value the cloud's detail names; otherwise upgrade `idside` or report it |
|
|
250
|
+
| `ConsentTokenError` | Request hash cannot be computed (non-JSON value) or token binding invalid | Pass JSON-compatible values or Pydantic models |
|
|
251
|
+
| `UnenforceableOptionError` | The call carries an option that would undo enforcement: a body or query rewrite, a per-call destination, a reserved header, a Gemini tool run by the library (raised before authorize) | Pass request parameters as named arguments; set destination and credentials on the client, route with `credentials=` |
|
|
252
|
+
| `MissingIdentityError` / `ConflictingIdentityError` | No identity / both identities passed | Pass exactly one of `user_id`, `ubt` |
|
|
253
|
+
| `UnsupportedProviderError` | No descriptor matches the client; the guard cannot cover it; or a derivation changes the origin, the provider, the transport, the query or the middleware | Check support, or `register_adapter()`; follow the message; build and wrap a separate client |
|
|
254
|
+
| `ToolUseRefusedError` | The call can hand the model tools — `tools`, `functions`, `web_search_options`, a stored conversation or prompt, a Mistral agent, a Gemini cache — and agentic tools are not accepted for this user: refused, never asked, or refused by a policy | Retry without them, or route the user to a non-agentic path; never retry the same call |
|
|
255
|
+
| `CredentialRoutingError` | The `credentials=` mapping cannot route; the client already sets the routing header in its default headers; or a derivation changes the credential or a reserved header | Fix the mapping — the message says which entry and why; route with `credentials=` |
|
|
256
|
+
| `MissingCredentialError` | This user's answer needs a posture you did not provision (`.posture`, `.provider`, `.provisioned`) | Provision that credential and label it; never retry |
|
|
257
|
+
| `ActVerificationError` | A proof did not check out during `verify_act()` (see below) | Reject the proof; the subclass says why |
|
|
258
|
+
|
|
259
|
+
## Verifying a proof — `verify_act()`
|
|
260
|
+
|
|
261
|
+
Anyone holding an ACT token can check it **offline**, with no call to ID Side and
|
|
262
|
+
no account: an auditor, a customer's compliance team, a provider, a supervisory
|
|
263
|
+
authority.
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from idside import verify_act
|
|
267
|
+
|
|
268
|
+
proof = verify_act(
|
|
269
|
+
token, # the X-AI-Consent-Token value
|
|
270
|
+
jwks_url="https://sdk.idside.eu/.well-known/jwks.json",
|
|
271
|
+
request_hash=request_hash, # one token = one request
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
proof.binding # "ubt": the widget bound the identity — the strong proof
|
|
275
|
+
proof.consent_state # {"training": False}
|
|
276
|
+
proof.app_id, proof.provider, proof.model, proof.token_id, proof.mode
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
What is checked, and never negotiated:
|
|
280
|
+
|
|
281
|
+
| Check | Rule |
|
|
282
|
+
| ----- | ---- |
|
|
283
|
+
| `alg` | pinned to `ES256`; `none`, `HS256` or anything else is a forgery attempt |
|
|
284
|
+
| `typ` | must be `act+jwt` — a User Binding Token (`ubt+jwt`) is not a proof |
|
|
285
|
+
| Signature | ES256 over `header.payload`, with the key named by `kid` |
|
|
286
|
+
| `kid` | must be in the JWKS; an unknown one is never worked around |
|
|
287
|
+
| Key set | must be public — a JWK carrying `d` or `k` is an incident, see below |
|
|
288
|
+
| Encoding | each segment must be *canonical* unpadded base64url |
|
|
289
|
+
| `exp` / `iat` / `nbf` | enforced, with 60 s of clock leeway (tune with `leeway=`) |
|
|
290
|
+
| Lifetime | `exp - iat` must not exceed 3600 seconds |
|
|
291
|
+
| Verification time | the real clock, or an epoch you pass as `now=` (see below) |
|
|
292
|
+
| `iss` | `idside.eu` only; `test.idside.eu` requires `allow_test_issuer=True` |
|
|
293
|
+
| `mode` | `test` or `live`; missing mode requires explicit `allow_legacy_without_mode=True` |
|
|
294
|
+
| Bindings | `request_hash=`, `nonce=`, `subject=`, `app_id=`, when you pass them |
|
|
295
|
+
|
|
296
|
+
Pass `jwks=` (a JWKS or a single JWK) instead of `jwks_url=` to verify without
|
|
297
|
+
any network access at all — that is the whole point of a local proof.
|
|
298
|
+
|
|
299
|
+
`proof.mode` is typed as `"test" | "live"`. When reading a known archive minted
|
|
300
|
+
before this claim existed, opt in with `allow_legacy_without_mode=True`; only
|
|
301
|
+
that compatibility path returns `proof.mode is None`.
|
|
302
|
+
|
|
303
|
+
### Auditing a past call
|
|
304
|
+
|
|
305
|
+
ACT tokens live one hour, so an archived proof is always expired against the
|
|
306
|
+
wall clock. `now=` is the verification time, as an epoch in seconds — set it to
|
|
307
|
+
ask *"was this proof valid when the call was made?"*:
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
proof = verify_act(token, jwks=jwks, now=ledger_entry["issued_at"])
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
It moves **only** the time window. The signature, `alg`, `typ`, the issuer and
|
|
314
|
+
every binding are checked exactly as before, so a generous `now` can never turn
|
|
315
|
+
a forgery into a proof.
|
|
316
|
+
|
|
317
|
+
### If your JWKS contains a private key
|
|
318
|
+
|
|
319
|
+
A JWK carrying `d` (the EC private scalar) or `k` (a symmetric key) is refused
|
|
320
|
+
with **`ActPrivateKeyInKeySetError`**, before any other check on that key. A key
|
|
321
|
+
set is public by definition, so private material in one means either a signing
|
|
322
|
+
key was published by mistake or the key set was poisoned — and verification is
|
|
323
|
+
the one moment where that can still be caught. Do not work around it: remove the
|
|
324
|
+
private members from the JWKS and **rotate the key**, assuming anything that key
|
|
325
|
+
signed can be forged.
|
|
326
|
+
|
|
327
|
+
It is deliberately **not** a subclass of `ActKeyNotFoundError`, so this:
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
except ActKeyNotFoundError:
|
|
331
|
+
refresh_jwks_and_retry() # right for a rotated key…
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
…does **not** swallow a poisoned key set and loop on your JWKS during the one
|
|
335
|
+
incident you need to escalate. The two conditions look alike and call for
|
|
336
|
+
opposite responses, so they are different classes rather than one class with a
|
|
337
|
+
different message.
|
|
338
|
+
|
|
339
|
+
This is also why non-canonical base64url is refused as `ActMalformedError`
|
|
340
|
+
rather than tolerated: one token has exactly one string form. Both SDKs agree
|
|
341
|
+
case by case — the shared vectors in
|
|
342
|
+
[`packages/descriptors/testdata/`](https://github.com/idside-eu/idside-sdk/tree/main/packages/descriptors/testdata)
|
|
343
|
+
pin the verdict
|
|
344
|
+
*and* the reason, so "verified" cannot come to mean two different things
|
|
345
|
+
depending on which library an auditor installed.
|
|
346
|
+
|
|
347
|
+
Every failure raises a subclass of `ActVerificationError`
|
|
348
|
+
(`ActAlgorithmError`, `ActTypeMismatchError`, `ActSignatureError`,
|
|
349
|
+
`ActExpiredError`, `ActNotYetValidError`, `ActIssuerError`,
|
|
350
|
+
`ActKeyNotFoundError`, `ActPrivateKeyInKeySetError`, `ActBindingMismatchError`,
|
|
351
|
+
`ActMalformedError`, `ActVerificationUnavailableError`). There is no boolean
|
|
352
|
+
return, so *"could not check"* is never mistaken for *"valid"*.
|
|
353
|
+
|
|
354
|
+
Verification needs one extra dependency — ES256 is the only thing the standard
|
|
355
|
+
library cannot do, and the core SDK stays dependency-light:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
pip install 'idside[verify]'
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Two things worth knowing:
|
|
362
|
+
|
|
363
|
+
- `subject=` pins the `sub` claim, which is the app-scoped **pseudonym** the
|
|
364
|
+
cloud derived from your end user. The raw `user_id` you passed to
|
|
365
|
+
`consent.wrap()` deliberately never enters the token, so it cannot be pinned.
|
|
366
|
+
- Replay of the *same* token on the *same* request is not detectable offline (a
|
|
367
|
+
verifier holds no history); that is the ledger's job. What a local check
|
|
368
|
+
proves is that this signed proof belongs to this exact request.
|
|
369
|
+
|
|
370
|
+
## How enforcement is extensible
|
|
371
|
+
|
|
372
|
+
Two concerns are kept separate:
|
|
373
|
+
|
|
374
|
+
- **What to force (the policy)** — which parameters correspond to a consent
|
|
375
|
+
state — is decided by the **cloud** and returned per-request as
|
|
376
|
+
`params_forced`. A provider renames a flag → ID Side updates a row, **no SDK
|
|
377
|
+
release**.
|
|
378
|
+
- **How to inject it (the mechanics)** — which methods to intercept, kwargs vs
|
|
379
|
+
headers, which kwarg carries per-request headers — is a small **JSON
|
|
380
|
+
descriptor** per provider (`src/idside/providers/*.json`), validated against
|
|
381
|
+
a shared schema in
|
|
382
|
+
[`packages/descriptors/`](https://github.com/idside-eu/idside-sdk/tree/main/packages/descriptors).
|
|
383
|
+
|
|
384
|
+
Standard providers need no Python. Clients whose API does not fit the
|
|
385
|
+
declarative model override the engine — the bundled Gemini adapter is the
|
|
386
|
+
example: `google-genai` takes no free-form kwargs, so it carries the token
|
|
387
|
+
inside `config.http_options.headers`:
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
from idside.adapters import ProviderAdapter, register_adapter
|
|
391
|
+
|
|
392
|
+
class MyProviderAdapter(ProviderAdapter):
|
|
393
|
+
... # override apply() / get_call_sites() as the client requires
|
|
394
|
+
|
|
395
|
+
register_adapter("myprovider", MyProviderAdapter) # name matches the descriptor
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
## Security model (short version)
|
|
399
|
+
|
|
400
|
+
- **Opaque tunnel** — the SDK has no API to read or write preferences; it only
|
|
401
|
+
applies decisions the cloud already made.
|
|
402
|
+
- **One token = one request** — each ACT token is bound to a `request_hash`
|
|
403
|
+
(RFC 8785/JCS canonical SHA-256 of the caller payload before enforcement,
|
|
404
|
+
excluding identity and request headers) + nonce + short TTL. Forced params
|
|
405
|
+
and constraint transformations happen afterward. An option that would change
|
|
406
|
+
the request after that point is refused, and so is anything that would send
|
|
407
|
+
several requests under one token.
|
|
408
|
+
Spec:
|
|
409
|
+
[`docs/spec/act-token.md`](https://github.com/idside-eu/idside-sdk/blob/main/docs/spec/act-token.md).
|
|
410
|
+
- **No secrets here** — tokens are signed in the ID Side cloud; this package
|
|
411
|
+
holds no private key and cannot sign anything.
|
|
412
|
+
- **Privacy by structure** — the SDK sends ID Side an identifier, a provider, a
|
|
413
|
+
model and a *hash*; prompts and completions never leave your backend.
|
|
414
|
+
|
|
415
|
+
## Development
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
uv sync --all-extras
|
|
419
|
+
uv run pytest # full suite — offline, free
|
|
420
|
+
uv run ruff check --fix && uv run ruff format
|
|
421
|
+
uv run mypy && uv run bandit -c pyproject.toml -r src
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
`pre-commit` (at the repo root) must pass before any commit. For tests, inject
|
|
425
|
+
a fake transport or adapter: `wrap(client, consent_client=..., adapter=...)`;
|
|
426
|
+
point at another API with `base_url=` / `api_key=`.
|
|
427
|
+
|
|
428
|
+
### Live provider tests (opt-in, billable)
|
|
429
|
+
|
|
430
|
+
`tests/live/` calls the five real provider APIs to check that each one actually
|
|
431
|
+
accepts what its descriptor says the SDK injects — and rejects what it says is
|
|
432
|
+
unforceable. It is the only way to keep `forceable_params` honest: a parameter
|
|
433
|
+
that exists in a client's signature but that the *server* ignores would pass
|
|
434
|
+
every offline test and enforce nothing.
|
|
435
|
+
|
|
436
|
+
They never run by default. Two independent guards see to that: `addopts` carries
|
|
437
|
+
`-m 'not live'`, and every test is skipped unless `IDSIDE_LIVE_PROVIDER_TESTS=1`.
|
|
438
|
+
CI runs the plain `uv run pytest`, so it never touches a provider.
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
export OPENAI_API_KEY=... ANTHROPIC_API_KEY=... GEMINI_API_KEY=...
|
|
442
|
+
export MISTRAL_API_KEY=... DEEPSEEK_API_KEY=...
|
|
443
|
+
IDSIDE_LIVE_PROVIDER_TESTS=1 uv run pytest -m live
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Missing keys skip their provider rather than fail, as do a spent quota and a
|
|
447
|
+
provider outage — neither says anything about whether our parameters are
|
|
448
|
+
acceptable. A rejected *parameter* always fails.
|
|
449
|
+
|
|
450
|
+
**Cost.** Every call is the smallest the provider accepts: the prompt `"hi"`,
|
|
451
|
+
one output token, the cheapest model. A full run is a fraction of a cent. Keep
|
|
452
|
+
it that way — these tests verify parameter plumbing, never model output. Each
|
|
453
|
+
model id is overridable (`IDSIDE_LIVE_OPENAI_MODEL`, `IDSIDE_LIVE_GEMINI_MODEL`,
|
|
454
|
+
…) because providers retire cheap models on short notice.
|
|
455
|
+
|
|
456
|
+
The ID Side cloud is *not* involved: a stub consent client supplies the decision,
|
|
457
|
+
so what is under test is the provider half of the contract. Each test asserts on
|
|
458
|
+
the request that left the process as well as on the answer that came back.
|
|
459
|
+
|
|
460
|
+
## Licence
|
|
461
|
+
|
|
462
|
+
Source-available, not open source. This package is licensed under the ID Side
|
|
463
|
+
Source-Available License, version 1.0 — `LICENSE` in this package, also
|
|
464
|
+
published at https://sdk.idside.eu/legal/sdk-licence. Clause 2 grants a
|
|
465
|
+
royalty-free licence to reproduce, execute and modify the SDK solely for
|
|
466
|
+
non-production internal evaluation, research, and non-commercial testing;
|
|
467
|
+
production deployment and any other commercial purpose require a paid
|
|
468
|
+
commercial licence. Beyond the limited evaluation patent licence of clause 2 of
|
|
469
|
+
`LICENSE`, rights under the patents are conveyed only by an executed commercial
|
|
470
|
+
licence. See `LICENSE` and `NOTICE` in this package, and `COMMERCIAL_LICENSE.md`
|
|
471
|
+
in the repository.
|
|
472
|
+
|
|
473
|
+
Patents: https://sdk.idside.eu/patents
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
idside/__about__.py,sha256=i--tcklQdZpBMB_1c0GBoix-nNua91oY6cLboSHSysA,224
|
|
2
|
+
idside/__init__.py,sha256=WwAIx7fi3T2P-2o629BjVM2eZyyskXNqJm1w3hdmPSE,4379
|
|
3
|
+
idside/_destination.py,sha256=uH4PFB2_gCQ7tuVzQzh721iFRgPgwEd7ji_Qs3BHYCo,6460
|
|
4
|
+
idside/_guard.py,sha256=y6JawV1OeTVz_jz0vfHKoN62T5FYW-kU9bz-cPitRPE,26538
|
|
5
|
+
idside/_jcs.py,sha256=2JvPsm_Mxlv5_Xa_8IPkDzPSqIj1Y6xkNslR0EEnUoU,4720
|
|
6
|
+
idside/_log.py,sha256=XBSYS79W96c6bKCHBULzP7iZVhQCj5pJTRZJR3OSkL0,7022
|
|
7
|
+
idside/_routing.py,sha256=xpUfnklCxKH1bXxpmjIHo77sbfqFGF2hliYCgIcGNRg,13529
|
|
8
|
+
idside/_transport.py,sha256=rt7TQLoRIFE5xOg95jFgipXey77-GxTnDHlQu6BFuSY,33652
|
|
9
|
+
idside/consent-options.json,sha256=8Oz8LL-vM_cPIiiYMDZWWOrfglsTUonJlhl2hUlctnc,16328
|
|
10
|
+
idside/consent.py,sha256=LLaCMBeNo8knP1wP6SesvT_v7dO7fJDP9gFhgQ3XHFk,70825
|
|
11
|
+
idside/exceptions.py,sha256=2eLihaI6tQM1Lir9pDDS0MAPnIyRFr8HMMwCrR_spaE,22736
|
|
12
|
+
idside/options.py,sha256=EWN5nN6p4gPWV20Sa7XbYwUL24RFbu0ivBJ0WaZN5CA,11075
|
|
13
|
+
idside/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
idside/verify.py,sha256=ErAWXvLiSe9-EsFcqwiZ9EgbjRcFGo2ATD0b8OgHfkg,31984
|
|
15
|
+
idside/adapters/__init__.py,sha256=FrbX7HKN9IOCzvswUOoOyIDKrZAhGtcqqBssVDafUMg,4536
|
|
16
|
+
idside/adapters/_call_options.py,sha256=DxT2c3PtmZHcPNU2p6bj1Hou9uKklP96YbfjW88iogM,38878
|
|
17
|
+
idside/adapters/base.py,sha256=M5s3ntys8h-lvYKx9VYntE2tJwgI8DItkdwUDWWV00I,20182
|
|
18
|
+
idside/adapters/gemini.py,sha256=kRCHn5mMVaDNRPkcl-sdhlW3CiZm0SDMv4O9oFEAvIo,3691
|
|
19
|
+
idside/providers/__init__.py,sha256=i4iAG9S7hTLkDfGUjlxFdvVm-xM7WhG_0x5jpXmN6Fs,11504
|
|
20
|
+
idside/providers/anthropic.json,sha256=BbhULhrmsATqPlqeB4QfctPt1-e3143Bfap6FAw27pQ,10789
|
|
21
|
+
idside/providers/deepseek.json,sha256=MWllhSJ3_ZD9hGeKMK8a2JTJVxBQisrJlGm6EEaCmyo,5924
|
|
22
|
+
idside/providers/gemini.json,sha256=XKouXq_ztbXDYLGLjNZxBPGsOJ6vyEqqr08lh_xM5CA,9386
|
|
23
|
+
idside/providers/mistral.json,sha256=H-qleH8vQQQkTEuAA-XO9SyvAShNR4tFS-3BEeA2ZxU,11947
|
|
24
|
+
idside/providers/openai.json,sha256=ZXkpSE0N-syPjzty60k7AX58K4eUgvoUA03ZnEDsHg8,17326
|
|
25
|
+
idside-0.1.0.dev0.dist-info/METADATA,sha256=4pET0LgHW0KurP4tFUJORQIE3r2zpPjYxJ2chUu4puc,26319
|
|
26
|
+
idside-0.1.0.dev0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
27
|
+
idside-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=ytaln5N4N7HOuaLax4kA1ne5wbgVS7eDLfz2Sa64GbE,18444
|
|
28
|
+
idside-0.1.0.dev0.dist-info/licenses/NOTICE,sha256=UEL1SM5lo4bcOSJQXiqBYSQqfmtjKX4_y7pXM2kSe48,1116
|
|
29
|
+
idside-0.1.0.dev0.dist-info/RECORD,,
|