trustgate-sdk 0.1.0__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.
trustgate/__init__.py ADDED
@@ -0,0 +1,95 @@
1
+ """TrustGate SDK - governed tools and models for agents.
2
+
3
+ An admin creates the consumer and decides what it may reach; this package
4
+ points an agent at it. What it adds on top of a plain HTTP call is the part
5
+ that is easy to get wrong: which actor a call speaks as, whether the tools the
6
+ agent was written around are still on its toolkit, and what to do when an
7
+ upstream account is not connected.
8
+ """
9
+
10
+ from .agent import Agent, EndUserAgent, Toolkit
11
+ from .client import LLMEndpoint, TrustGate
12
+ from .config import API_KEY_HEADER, END_USER_HEADER
13
+ from .errors import (
14
+ AuthenticationError,
15
+ ConsentRequiredError,
16
+ InvalidRequestError,
17
+ MissingToolsError,
18
+ PlaneUnavailableError,
19
+ PolicyBlockedError,
20
+ RateLimitedError,
21
+ ServiceUnavailableError,
22
+ ToolNotFoundError,
23
+ TrustGateError,
24
+ TrustGateServerError,
25
+ UpstreamNotConnectedError,
26
+ )
27
+ from .formats import ConversionWarning, adapter_for, result_to_text
28
+ from .mcp import MCPTransport
29
+ from .schema import StrictResult, inline_refs, strip_injected_nulls, to_strict
30
+ from .transport import Response, Transport, UrllibTransport
31
+ from .types import (
32
+ Actor,
33
+ Connection,
34
+ ConnectLink,
35
+ Endpoint,
36
+ GatewayTool,
37
+ ToolCall,
38
+ ToolFormat,
39
+ )
40
+ from .whoami import (
41
+ KeyConsumer,
42
+ KeyIdentity,
43
+ KeyInfo,
44
+ KeyUpstream,
45
+ select_consumer,
46
+ who_am_i,
47
+ )
48
+
49
+ __version__ = "0.1.0"
50
+
51
+ __all__ = [
52
+ "Actor",
53
+ "Agent",
54
+ "API_KEY_HEADER",
55
+ "AuthenticationError",
56
+ "ConnectLink",
57
+ "Connection",
58
+ "ConsentRequiredError",
59
+ "ConversionWarning",
60
+ "END_USER_HEADER",
61
+ "Endpoint",
62
+ "EndUserAgent",
63
+ "GatewayTool",
64
+ "KeyConsumer",
65
+ "KeyIdentity",
66
+ "KeyInfo",
67
+ "KeyUpstream",
68
+ "InvalidRequestError",
69
+ "LLMEndpoint",
70
+ "MCPTransport",
71
+ "MissingToolsError",
72
+ "PlaneUnavailableError",
73
+ "PolicyBlockedError",
74
+ "RateLimitedError",
75
+ "Response",
76
+ "ServiceUnavailableError",
77
+ "StrictResult",
78
+ "ToolCall",
79
+ "ToolFormat",
80
+ "ToolNotFoundError",
81
+ "Toolkit",
82
+ "Transport",
83
+ "TrustGate",
84
+ "TrustGateError",
85
+ "TrustGateServerError",
86
+ "UpstreamNotConnectedError",
87
+ "UrllibTransport",
88
+ "adapter_for",
89
+ "inline_refs",
90
+ "result_to_text",
91
+ "select_consumer",
92
+ "strip_injected_nulls",
93
+ "to_strict",
94
+ "who_am_i",
95
+ ]
trustgate/agent.py ADDED
@@ -0,0 +1,208 @@
1
+ """The handles `connect()` hands out, one per actor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .config import END_USER_HEADER, Config
8
+ from .connections import create_connect_link, list_connections, require_end_user
9
+ from .formats import ConversionWarning, ToolResult, adapter_for, restore_arguments
10
+ from .mcp import MCPTransport
11
+ from .schema import Schema
12
+ from .transport import Transport
13
+ from .types import (
14
+ Actor,
15
+ Connection,
16
+ ConnectLink,
17
+ Endpoint,
18
+ GatewayTool,
19
+ ToolCall,
20
+ ToolFormat,
21
+ resolve_tool_name,
22
+ )
23
+
24
+
25
+ class Toolkit:
26
+ """A tool surface in one provider's dialect, with the executor that belongs to it.
27
+
28
+ They travel together because they are two halves of one translation: what
29
+ ``tools`` added on the way out, ``execute`` has to undo on the way back.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ tools: list[Any],
35
+ warnings: list[ConversionWarning],
36
+ tool_format: ToolFormat,
37
+ originals: dict[str, Schema],
38
+ transport: MCPTransport,
39
+ ) -> None:
40
+ #: Pass this straight to the provider's API.
41
+ self.tools = tools
42
+ #: Tools whose schema could not be expressed in the requested dialect.
43
+ self.warnings = warnings
44
+ self._format = tool_format
45
+ self._originals = originals
46
+ self._transport = transport
47
+
48
+ def calls(self, output: Any) -> list[ToolCall]:
49
+ """The calls the model asked for, read out of the provider's response."""
50
+ return adapter_for(self._format).extract_calls(output)
51
+
52
+ def execute(self, output: Any) -> list[Any]:
53
+ """Runs the calls the model asked for and returns what to send back.
54
+
55
+ Every call goes to the gateway, so the policy, the audit trail and the
56
+ upstream credentials stay where they were. The caller's process only
57
+ decides whether to make the call at all.
58
+ """
59
+ adapter = adapter_for(self._format)
60
+ results = []
61
+ for call in adapter.extract_calls(output):
62
+ arguments = restore_arguments(call.arguments, self._originals.get(call.name))
63
+ result = self._transport.call_tool(call.name, arguments)
64
+ results.append(ToolResult(call=call, result=result))
65
+ return adapter.to_outputs(results)
66
+
67
+
68
+ class _ToolSurface:
69
+ """What both handles share: a surface, and the ways to spend it."""
70
+
71
+ def __init__(self, transport: MCPTransport, tools: list[GatewayTool]) -> None:
72
+ self._transport = transport
73
+ self.tools = tools
74
+
75
+ @property
76
+ def mcp(self) -> Endpoint:
77
+ """URL and headers for a framework that brings its own MCP client."""
78
+ return Endpoint(url=self._transport.url, headers=self._transport.headers)
79
+
80
+ def toolkit(self, tool_format: ToolFormat | str, strict: bool = False) -> Toolkit:
81
+ """The same surface, translated for a provider you call directly."""
82
+ resolved = ToolFormat(tool_format)
83
+ conversion = adapter_for(resolved).convert(self.tools, strict)
84
+ return Toolkit(
85
+ conversion.tools, conversion.warnings, resolved, conversion.originals, self._transport
86
+ )
87
+
88
+ def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
89
+ """One tool, called directly. The escape hatch under the toolkits.
90
+
91
+ The server prefix is optional here: "list_issues" reaches
92
+ "linear_list_issues" while Linear is the only server of this application
93
+ that serves it. The gateway put that prefix there, so a caller writing
94
+ the name by hand should not have to.
95
+ """
96
+ return self._transport.call_tool(resolve_tool_name(name, self.tools), arguments)
97
+
98
+ def refresh(self) -> list[GatewayTool]:
99
+ """Re-reads the surface.
100
+
101
+ An admin owns this toolkit and can change it under a running agent, so
102
+ a long-lived process re-reads rather than trusting the list it took at
103
+ startup.
104
+ """
105
+ self.tools = self._transport.list_tools()
106
+ return self.tools
107
+
108
+
109
+ class Agent(_ToolSurface):
110
+ """An application's handle on its gateway.
111
+
112
+ It speaks as the application itself: one principal, its own upstream
113
+ accounts, nothing per-user. Which handle you get is not a choice made here -
114
+ it follows from how the consumer was configured, which is why ``connect()``
115
+ returns one of these or refuses.
116
+ """
117
+
118
+ actor = Actor.APPLICATION
119
+
120
+ def __init__(
121
+ self,
122
+ config: Config,
123
+ transport_impl: Transport,
124
+ slug: str,
125
+ transport: MCPTransport,
126
+ tools: list[GatewayTool],
127
+ connections: list[Connection],
128
+ ) -> None:
129
+ super().__init__(transport, tools)
130
+ self._config = config
131
+ self._http = transport_impl
132
+ self.slug = slug
133
+ #: The application's own upstream accounts, as of ``connect()``.
134
+ self.connections = connections
135
+
136
+ def refresh_connections(self) -> list[Connection]:
137
+ """What the application still owes before it can call every server."""
138
+ return list_connections(self._config, self._http, self.slug)
139
+
140
+ def for_end_user(self, end_user: str) -> EndUserAgent:
141
+ """The same application, acting for one named person.
142
+
143
+ No round trip and no second surface to read: the toolkit an admin bound
144
+ is the application's, identical for everyone it acts for. What changes
145
+ is one header, and with it whose upstream account the gateway reaches
146
+ for.
147
+ """
148
+ return end_user_agent(
149
+ self._config,
150
+ self._http,
151
+ self.slug,
152
+ end_user,
153
+ self._transport.url,
154
+ self.tools,
155
+ )
156
+
157
+
158
+ class EndUserAgent(_ToolSurface):
159
+ """An application's handle for one of its own end users.
160
+
161
+ The user travels in a header, so a handle is a header and nothing more -
162
+ but the MCP endpoint's headers are fixed when a client connects, which is
163
+ why each user needs their own transport rather than a shared one.
164
+ """
165
+
166
+ actor = Actor.END_USER
167
+
168
+ def __init__(
169
+ self,
170
+ config: Config,
171
+ transport_impl: Transport,
172
+ slug: str,
173
+ end_user: str,
174
+ transport: MCPTransport,
175
+ tools: list[GatewayTool],
176
+ ) -> None:
177
+ super().__init__(transport, tools)
178
+ self._config = config
179
+ self._http = transport_impl
180
+ self.slug = slug
181
+ self.end_user = end_user
182
+
183
+ def connections(self) -> list[Connection]:
184
+ """Which servers this user has connected, and which they have not."""
185
+ return list_connections(self._config, self._http, self.slug, self.end_user)
186
+
187
+ def connect_link(self, provider: str | None = None) -> ConnectLink:
188
+ """The page to put in front of this user so they can connect an account.
189
+
190
+ Naming a provider narrows it to that one server; omitting it covers
191
+ every server of the application that forwards a credential. The link
192
+ expires, so it is minted when it is about to be shown, not cached.
193
+ """
194
+ return create_connect_link(self._config, self._http, self.slug, self.end_user, provider)
195
+
196
+
197
+ def end_user_agent(
198
+ config: Config,
199
+ transport_impl: Transport,
200
+ slug: str,
201
+ raw_end_user: str,
202
+ url: str,
203
+ tools: list[GatewayTool],
204
+ ) -> EndUserAgent:
205
+ """Builds the per-user handle, with the header that names them."""
206
+ end_user = require_end_user(raw_end_user)
207
+ transport = MCPTransport(config, transport_impl, url, {END_USER_HEADER: end_user})
208
+ return EndUserAgent(config, transport_impl, slug, end_user, transport, tools)
trustgate/client.py ADDED
@@ -0,0 +1,180 @@
1
+ """The entry point: a gateway and a key, and everything else is asked for."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .agent import Agent, EndUserAgent, end_user_agent
8
+ from .config import API_KEY_HEADER, resolve_config
9
+ from .connections import list_connections
10
+ from .errors import MissingToolsError, UpstreamNotConnectedError
11
+ from .mcp import MCPTransport
12
+ from .transport import Transport, UrllibTransport
13
+ from .types import CONNECTED, Connection, GatewayTool, resolve_tool_name
14
+ from .whoami import KeyIdentity, KeyUpstream, select_consumer, who_am_i
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class LLMEndpoint:
19
+ """What the LLM plane needs to be handed to a provider's own client."""
20
+
21
+ #: Pass as ``base_url`` to the OpenAI client. It ends in ``/v1``.
22
+ base_url: str
23
+ api_key: str
24
+ headers: dict[str, str]
25
+ #: The consumer behind it, for logs and for error messages.
26
+ consumer: str
27
+
28
+ @property
29
+ def anthropic_base_url(self) -> str:
30
+ """Pass as ``base_url`` to the Anthropic client.
31
+
32
+ The two clients disagree on where the version goes. OpenAI's is handed
33
+ a base that already ends in ``/v1`` and appends ``/chat/completions``;
34
+ Anthropic's appends ``/v1/messages`` to what it is given, so handing it
35
+ ``base_url`` asks the gateway for ``/v1/v1/messages``. This is the
36
+ application's root, the one every dialect but OpenAI's hangs from.
37
+ """
38
+ return _without_version(self.base_url)
39
+
40
+
41
+ def _without_version(url: str) -> str:
42
+ trimmed = url.rstrip("/")
43
+ return trimmed[: -len("/v1")] if trimmed.endswith("/v1") else trimmed
44
+
45
+
46
+ class TrustGate:
47
+ """A gateway and a key, and everything else is asked for.
48
+
49
+ The key is attached to consumers, and a consumer has one type - so the
50
+ tools live behind an MCP consumer and the models behind an LLM one. Their
51
+ slugs were chosen by whoever created them, and the two planes do not share
52
+ a host, so neither is something a caller should have to carry: the gateway
53
+ is asked once, at ``connect()``, and answers both.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ base_url: str | None = None,
59
+ api_key: str | None = None,
60
+ mcp_consumer: str | None = None,
61
+ llm_consumer: str | None = None,
62
+ timeout: float = 30.0,
63
+ transport: Transport | None = None,
64
+ ) -> None:
65
+ self._config = resolve_config(base_url, api_key, mcp_consumer, llm_consumer, timeout)
66
+ self._transport = transport or UrllibTransport()
67
+ self._identity: KeyIdentity | None = None
68
+
69
+ def identity(self) -> KeyIdentity:
70
+ """What this key reaches.
71
+
72
+ Read once and remembered: it is a property of the key, and a
73
+ long-lived process should not re-ask on every call.
74
+ """
75
+ if self._identity is None:
76
+ self._identity = who_am_i(self._config, self._transport)
77
+ return self._identity
78
+
79
+ def llm(self) -> LLMEndpoint:
80
+ """The LLM plane, ready for a provider's own SDK.
81
+
82
+ The gateway speaks the providers' own APIs, so nothing here wraps their
83
+ clients - it points them somewhere else. Wrapping would mean chasing
84
+ every change they make and breaking streaming on the way.
85
+ """
86
+ consumer = select_consumer(
87
+ self.identity(), "LLM", self._config.llm_consumer, "llm_consumer"
88
+ )
89
+ return LLMEndpoint(
90
+ base_url=consumer.url,
91
+ api_key=self._config.api_key,
92
+ headers={API_KEY_HEADER: self._config.api_key},
93
+ consumer=consumer.slug,
94
+ )
95
+
96
+ def connect(self, requires: list[str] | None = None) -> Agent:
97
+ """Opens the application's own surface and proves it is usable.
98
+
99
+ Two things happen here, and both are the kind that are cheap now and
100
+ expensive later: whether the tools the agent needs are actually on its
101
+ toolkit, and whether the servers behind it have an account to call
102
+ with. The second has no runtime remedy for this handle - nobody is
103
+ present to open a connect link once a batch is going - which is the
104
+ whole reason it is checked at startup.
105
+
106
+ This is the application actor: the key and nothing else, so the gateway
107
+ runs the calls as ``app:<consumer_id>``. For a call on behalf of a
108
+ person, use :meth:`for_end_user`; both work on the same consumer,
109
+ because who a request runs as is read from the request rather than
110
+ declared anywhere.
111
+ """
112
+ consumer = select_consumer(
113
+ self.identity(), "MCP", self._config.mcp_consumer, "mcp_consumer"
114
+ )
115
+ # Accounts before tools: a server with no account for the application
116
+ # can fail the listing itself, which would surface as a bare gateway
117
+ # error before this check - the one that says who fixes it - ever ran.
118
+ connections = list_connections(self._config, self._transport, consumer.slug)
119
+ blocked = _blocked_upstreams(consumer.upstreams, connections)
120
+ if blocked:
121
+ raise UpstreamNotConnectedError(blocked)
122
+
123
+ transport = MCPTransport(self._config, self._transport, consumer.url)
124
+ tools = transport.list_tools()
125
+ _check_requires(tools, list(requires or []))
126
+
127
+ return Agent(self._config, self._transport, consumer.slug, transport, tools, connections)
128
+
129
+ def for_end_user(self, end_user: str, requires: list[str] | None = None) -> EndUserAgent:
130
+ """The handle for one named person, on the same consumer and the same key.
131
+
132
+ The name is asserted by this application and not verified, so the
133
+ gateway namespaces it: two applications naming ``user_123`` never share
134
+ an account. What that person still has to connect is theirs to connect
135
+ - the handle's own connections mint the link to put in front of them -
136
+ which is why there is no startup preflight here and one in
137
+ :meth:`connect`.
138
+ """
139
+ consumer = select_consumer(
140
+ self.identity(), "MCP", self._config.mcp_consumer, "mcp_consumer"
141
+ )
142
+ agent = end_user_agent(
143
+ self._config, self._transport, consumer.slug, end_user, consumer.url, []
144
+ )
145
+ _check_requires(agent.refresh(), list(requires or []))
146
+ return agent
147
+
148
+
149
+ def _check_requires(tools: list[GatewayTool], requires: list[str]) -> None:
150
+ """The tools an agent was written around, checked before anything runs.
151
+
152
+ Each one is resolved the way call_tool resolves it, so an agent may require
153
+ the name its server gave the tool and leave the gateway's server prefix to
154
+ the gateway.
155
+ """
156
+ names = {tool.name for tool in tools}
157
+ missing = [name for name in requires if resolve_tool_name(name, tools) not in names]
158
+ if missing:
159
+ raise MissingToolsError(missing, sorted(names))
160
+
161
+
162
+ def _blocked_upstreams(
163
+ upstreams: list[KeyUpstream] | None, connections: list[Connection]
164
+ ) -> list[KeyUpstream]:
165
+ """What this application still has to have connected before it can run.
166
+
167
+ ``whoami`` answers it best, because it also names who has to act. But the
168
+ field is absent on a gateway too old to send it, and an absent list is not
169
+ an empty one: taking it for "nothing to connect" is how a batch gets past
170
+ its own startup check and fails on the first row instead, which is the
171
+ failure the check exists to prevent. So when it is missing the connections
172
+ list answers, as it did before ``whoami`` carried this at all.
173
+ """
174
+ if upstreams is not None:
175
+ return [upstream for upstream in upstreams if upstream.blocked]
176
+ return [
177
+ KeyUpstream(server=connection.registry or connection.provider)
178
+ for connection in connections
179
+ if connection.status != CONNECTED
180
+ ]
trustgate/config.py ADDED
@@ -0,0 +1,51 @@
1
+ """Where the SDK gets its gateway, its key and its consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ from .errors import TrustGateError
9
+
10
+ #: The header the gateway reads the consumer's API key from. It also accepts
11
+ #: ``x-api-key`` and ``Authorization: Bearer ag_...``; this one is the
12
+ #: unambiguous spelling, so it is the one the SDK sends.
13
+ API_KEY_HEADER = "X-AG-API-Key"
14
+
15
+ #: The header that names which of the application's end users a call is for.
16
+ END_USER_HEADER = "X-NeuralTrust-End-User"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Config:
21
+ base_url: str
22
+ api_key: str
23
+ mcp_consumer: str | None = None
24
+ llm_consumer: str | None = None
25
+ timeout: float = 30.0
26
+
27
+
28
+ def resolve_config(
29
+ base_url: str | None = None,
30
+ api_key: str | None = None,
31
+ mcp_consumer: str | None = None,
32
+ llm_consumer: str | None = None,
33
+ timeout: float = 30.0,
34
+ ) -> Config:
35
+ resolved_url = (base_url or os.environ.get("TRUSTGATE_URL") or "").strip().rstrip("/")
36
+ resolved_key = (api_key or os.environ.get("TRUSTGATE_API_KEY") or "").strip()
37
+ if not resolved_url:
38
+ raise TrustGateError("base_url is required (or set TRUSTGATE_URL)")
39
+ if not resolved_url.startswith(("http://", "https://")):
40
+ raise TrustGateError(f'base_url must be an http(s) URL, got "{resolved_url}"')
41
+ if not resolved_key:
42
+ raise TrustGateError("api_key is required (or set TRUSTGATE_API_KEY)")
43
+ return Config(
44
+ base_url=resolved_url,
45
+ api_key=resolved_key,
46
+ mcp_consumer=(mcp_consumer or os.environ.get("TRUSTGATE_MCP_CONSUMER") or "").strip()
47
+ or None,
48
+ llm_consumer=(llm_consumer or os.environ.get("TRUSTGATE_LLM_CONSUMER") or "").strip()
49
+ or None,
50
+ timeout=timeout,
51
+ )
@@ -0,0 +1,100 @@
1
+ """The connections API: which upstream accounts an actor has, and the link to add one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime
7
+ from typing import Any
8
+ from urllib.parse import quote
9
+
10
+ from .config import API_KEY_HEADER, END_USER_HEADER, Config
11
+ from .errors import InvalidRequestError
12
+ from .transport import Transport, error_for_response
13
+ from .types import Connection, ConnectLink
14
+
15
+
16
+ def connections_path(slug: str, end_user: str | None = None) -> str:
17
+ query = f"?end_user={quote(end_user)}" if end_user else ""
18
+ return f"/{quote(slug)}/connections{query}"
19
+
20
+
21
+ def list_connections(
22
+ config: Config, transport: Transport, slug: str, end_user: str | None = None
23
+ ) -> list[Connection]:
24
+ response = transport.request(
25
+ "GET",
26
+ f"{config.base_url}{connections_path(slug, end_user)}",
27
+ {API_KEY_HEADER: config.api_key, "Accept": "application/json"},
28
+ None,
29
+ config.timeout,
30
+ )
31
+ if response.status >= 400:
32
+ raise error_for_response(response)
33
+ body = response.json() or {}
34
+ return [_to_connection(item) for item in (body.get("connections") or [])]
35
+
36
+
37
+ def create_connect_link(
38
+ config: Config,
39
+ transport: Transport,
40
+ slug: str,
41
+ end_user: str,
42
+ provider: str | None = None,
43
+ ) -> ConnectLink:
44
+ payload: dict[str, Any] = {"end_user": end_user}
45
+ if provider:
46
+ payload["provider"] = provider
47
+ response = transport.request(
48
+ "POST",
49
+ f"{config.base_url}/{quote(slug)}/connections/links",
50
+ {
51
+ API_KEY_HEADER: config.api_key,
52
+ END_USER_HEADER: end_user,
53
+ "Accept": "application/json",
54
+ "Content-Type": "application/json",
55
+ },
56
+ json.dumps(payload).encode("utf-8"),
57
+ config.timeout,
58
+ )
59
+ if response.status >= 400:
60
+ raise error_for_response(response)
61
+ body = response.json() or {}
62
+ return ConnectLink(
63
+ connect_url=body.get("connect_url", ""),
64
+ ticket=body.get("ticket", ""),
65
+ provider=body.get("provider") or None,
66
+ expires_at=_to_datetime(body.get("expires_at")) or datetime.now(),
67
+ )
68
+
69
+
70
+ def require_end_user(end_user: str) -> str:
71
+ """The gateway is the authority on what an end-user id may be.
72
+
73
+ This only catches the mistake worth catching locally, which is the empty
74
+ one - it would otherwise be sent as a header the gateway reads as "no user
75
+ named" and answer for the wrong actor.
76
+ """
77
+ trimmed = (end_user or "").strip()
78
+ if not trimmed:
79
+ raise InvalidRequestError("an end-user id is required to act for a user")
80
+ return trimmed
81
+
82
+
83
+ def _to_connection(payload: dict[str, Any]) -> Connection:
84
+ return Connection(
85
+ provider=payload.get("provider", ""),
86
+ status=payload.get("status", ""),
87
+ registry=payload.get("registry") or None,
88
+ code=payload.get("code") or None,
89
+ account_ref=payload.get("account_ref") or None,
90
+ expires_at=_to_datetime(payload.get("expires_at")),
91
+ )
92
+
93
+
94
+ def _to_datetime(raw: Any) -> datetime | None:
95
+ if not raw or not isinstance(raw, str):
96
+ return None
97
+ try:
98
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
99
+ except ValueError:
100
+ return None