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 +95 -0
- trustgate/agent.py +208 -0
- trustgate/client.py +180 -0
- trustgate/config.py +51 -0
- trustgate/connections.py +100 -0
- trustgate/errors.py +180 -0
- trustgate/formats.py +390 -0
- trustgate/mcp.py +179 -0
- trustgate/py.typed +0 -0
- trustgate/schema.py +185 -0
- trustgate/transport.py +113 -0
- trustgate/types.py +116 -0
- trustgate/whoami.py +184 -0
- trustgate_sdk-0.1.0.dist-info/METADATA +160 -0
- trustgate_sdk-0.1.0.dist-info/RECORD +17 -0
- trustgate_sdk-0.1.0.dist-info/WHEEL +4 -0
- trustgate_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
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
|
+
)
|
trustgate/connections.py
ADDED
|
@@ -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
|