@neuraltrust/trustgate 0.1.0
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.
- package/LICENSE +201 -0
- package/README.md +132 -0
- package/dist/agent.d.ts +156 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +216 -0
- package/dist/agent.js.map +1 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +152 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +40 -0
- package/dist/config.js.map +1 -0
- package/dist/connections.d.ts +13 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +46 -0
- package/dist/connections.js.map +1 -0
- package/dist/errors.d.ts +91 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +144 -0
- package/dist/errors.js.map +1 -0
- package/dist/formats.d.ts +44 -0
- package/dist/formats.d.ts.map +1 -0
- package/dist/formats.js +299 -0
- package/dist/formats.js.map +1 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +94 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +43 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +175 -0
- package/dist/mcp.js.map +1 -0
- package/dist/schema.d.ts +52 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +168 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +52 -0
- package/dist/types.js.map +1 -0
- package/dist/whoami.d.ts +73 -0
- package/dist/whoami.d.ts.map +1 -0
- package/dist/whoami.js +79 -0
- package/dist/whoami.js.map +1 -0
- package/package.json +29 -0
- package/src/agent.ts +267 -0
- package/src/client.ts +203 -0
- package/src/config.ts +78 -0
- package/src/connections.ts +90 -0
- package/src/errors.ts +154 -0
- package/src/formats.ts +362 -0
- package/src/http.ts +106 -0
- package/src/index.ts +41 -0
- package/src/mcp.ts +203 -0
- package/src/schema.ts +193 -0
- package/src/types.ts +98 -0
- package/src/whoami.ts +172 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Agent, EndUserAgent, endUserAgent } from './agent.js'
|
|
2
|
+
import { API_KEY_HEADER, resolveConfig, type ResolvedConfig, type TrustGateConfig } from './config.js'
|
|
3
|
+
import { listConnections } from './connections.js'
|
|
4
|
+
import {
|
|
5
|
+
MissingToolsError,
|
|
6
|
+
TrustGateError,
|
|
7
|
+
UpstreamNotConnectedError,
|
|
8
|
+
type BlockedUpstream,
|
|
9
|
+
} from './errors.js'
|
|
10
|
+
import { MCPTransport } from './mcp.js'
|
|
11
|
+
import { resolveToolName, type Connection, type GatewayTool } from './types.js'
|
|
12
|
+
import { selectConsumer, whoAmI, type KeyIdentity, type KeyUpstream } from './whoami.js'
|
|
13
|
+
|
|
14
|
+
export type ConnectOptions = {
|
|
15
|
+
/**
|
|
16
|
+
* Tool names this agent is written around.
|
|
17
|
+
*
|
|
18
|
+
* The toolkit belongs to an admin, not to the code that uses it, so it can
|
|
19
|
+
* be narrowed without warning. Declaring what you need turns that into a
|
|
20
|
+
* refusal at startup instead of a failure mid-conversation.
|
|
21
|
+
*/
|
|
22
|
+
requires?: string[]
|
|
23
|
+
signal?: AbortSignal
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the LLM plane needs to be handed to a provider's own client. */
|
|
27
|
+
export type LLMEndpoint = {
|
|
28
|
+
/** Pass as `baseURL` to the OpenAI client. It ends in `/v1`. */
|
|
29
|
+
baseUrl: string
|
|
30
|
+
/**
|
|
31
|
+
* Pass as `baseURL` to the Anthropic client.
|
|
32
|
+
*
|
|
33
|
+
* The two clients disagree on where the version goes. OpenAI's is handed a
|
|
34
|
+
* base that already ends in `/v1` and appends `/chat/completions`;
|
|
35
|
+
* Anthropic's appends `/v1/messages` to what it is given, so handing it
|
|
36
|
+
* `baseUrl` asks the gateway for `/v1/v1/messages`. This is the
|
|
37
|
+
* application's root, the one every dialect but OpenAI's hangs from.
|
|
38
|
+
*/
|
|
39
|
+
anthropicBaseUrl: string
|
|
40
|
+
apiKey: string
|
|
41
|
+
headers: Record<string, string>
|
|
42
|
+
/** The consumer behind it, for logs and for error messages. */
|
|
43
|
+
consumer: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The entry point: 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 tools
|
|
50
|
+
* live behind an MCP consumer and the models behind an LLM one. Their slugs
|
|
51
|
+
* were chosen by whoever created them, and the two planes do not share a host,
|
|
52
|
+
* so neither is something a caller should have to carry: the gateway is asked
|
|
53
|
+
* once, at `connect()`, and answers both.
|
|
54
|
+
*/
|
|
55
|
+
export class TrustGate {
|
|
56
|
+
private readonly config: ResolvedConfig
|
|
57
|
+
private identityPromise?: Promise<KeyIdentity>
|
|
58
|
+
|
|
59
|
+
constructor(config: TrustGateConfig = {}) {
|
|
60
|
+
this.config = resolveConfig(config)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What this key reaches. Read once and remembered: it is a property of the
|
|
65
|
+
* key, and a long-lived process should not re-ask on every call.
|
|
66
|
+
*/
|
|
67
|
+
async identity(signal?: AbortSignal): Promise<KeyIdentity> {
|
|
68
|
+
this.identityPromise ??= whoAmI(this.config, signal).catch((error: unknown) => {
|
|
69
|
+
this.identityPromise = undefined
|
|
70
|
+
throw asIdentityError(error, this.config.baseUrl)
|
|
71
|
+
})
|
|
72
|
+
return this.identityPromise
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The LLM plane, ready for a provider's own SDK.
|
|
77
|
+
*
|
|
78
|
+
* The gateway speaks the providers' own APIs, so nothing here wraps their
|
|
79
|
+
* clients — it points them somewhere else. Wrapping would mean chasing
|
|
80
|
+
* every change they make and breaking streaming on the way.
|
|
81
|
+
*/
|
|
82
|
+
async llm(signal?: AbortSignal): Promise<LLMEndpoint> {
|
|
83
|
+
const identity = await this.identity(signal)
|
|
84
|
+
const consumer = selectConsumer(identity, 'LLM', this.config.llmConsumer, 'llmConsumer')
|
|
85
|
+
return {
|
|
86
|
+
baseUrl: consumer.url,
|
|
87
|
+
anthropicBaseUrl: withoutVersion(consumer.url),
|
|
88
|
+
apiKey: this.config.apiKey,
|
|
89
|
+
headers: { [API_KEY_HEADER]: this.config.apiKey },
|
|
90
|
+
consumer: consumer.slug,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Opens the application's own surface and proves it is usable before
|
|
96
|
+
* anything runs.
|
|
97
|
+
*
|
|
98
|
+
* Two things happen here, and both are the kind that are cheap now and
|
|
99
|
+
* expensive later: whether the tools the agent needs are actually on its
|
|
100
|
+
* toolkit, and whether the servers behind it have an account to call with.
|
|
101
|
+
* The second has no runtime remedy for this handle — nobody is present to
|
|
102
|
+
* open a connect link once a batch is going — which is the whole reason it
|
|
103
|
+
* is checked at startup.
|
|
104
|
+
*
|
|
105
|
+
* This is the application actor: the key and nothing else, so the gateway
|
|
106
|
+
* runs the calls as `app:<consumer_id>`. For a call on behalf of a person,
|
|
107
|
+
* use {@link forEndUser}; both work on the same consumer, because who a
|
|
108
|
+
* request runs as is read from the request rather than declared anywhere.
|
|
109
|
+
*/
|
|
110
|
+
async connect(options: ConnectOptions = {}): Promise<Agent> {
|
|
111
|
+
const identity = await this.identity(options.signal)
|
|
112
|
+
const consumer = selectConsumer(identity, 'MCP', this.config.mcpConsumer, 'mcpConsumer')
|
|
113
|
+
|
|
114
|
+
// Accounts before tools: a server with no account for the application can
|
|
115
|
+
// fail the listing itself, which would surface as a bare gateway error
|
|
116
|
+
// before this check — the one that says who fixes it — ever ran.
|
|
117
|
+
const connections = await listConnections(this.config, consumer.slug, undefined, options.signal)
|
|
118
|
+
const blocked = blockedUpstreams(consumer.upstreams, connections)
|
|
119
|
+
if (blocked.length > 0) {
|
|
120
|
+
throw new UpstreamNotConnectedError(blocked)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const transport = new MCPTransport(this.config, consumer.url)
|
|
124
|
+
const tools = await transport.listTools(options.signal)
|
|
125
|
+
const missing = missingTools(tools, options.requires ?? [])
|
|
126
|
+
if (missing.length > 0) {
|
|
127
|
+
throw new MissingToolsError(missing, tools.map((tool) => tool.name))
|
|
128
|
+
}
|
|
129
|
+
return new Agent(this.config, consumer.slug, transport, tools, missing, connections)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The handle for one named person, on the same consumer and the same key.
|
|
134
|
+
*
|
|
135
|
+
* The name is asserted by this application and not verified, so the gateway
|
|
136
|
+
* namespaces it: two applications naming `user_123` never share an account.
|
|
137
|
+
* What that person still has to connect is theirs to connect — the handle's
|
|
138
|
+
* own `connections` mint the link to put in front of them — which is why
|
|
139
|
+
* there is no startup preflight here and one in {@link connect}.
|
|
140
|
+
*/
|
|
141
|
+
async forEndUser(endUser: string, options: ConnectOptions = {}): Promise<EndUserAgent> {
|
|
142
|
+
const identity = await this.identity(options.signal)
|
|
143
|
+
const consumer = selectConsumer(identity, 'MCP', this.config.mcpConsumer, 'mcpConsumer')
|
|
144
|
+
const agent = endUserAgent(this.config, consumer.slug, endUser, consumer.url, [])
|
|
145
|
+
const tools = await agent.refresh(options.signal)
|
|
146
|
+
const missing = missingTools(tools, options.requires ?? [])
|
|
147
|
+
if (missing.length > 0) {
|
|
148
|
+
throw new MissingToolsError(missing, tools.map((tool) => tool.name))
|
|
149
|
+
}
|
|
150
|
+
return agent
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The required tools this toolkit does not carry.
|
|
156
|
+
*
|
|
157
|
+
* Each name is resolved the way callTool resolves it, so an agent may require
|
|
158
|
+
* the name its server gave the tool and leave the gateway's server prefix to
|
|
159
|
+
* the gateway.
|
|
160
|
+
*/
|
|
161
|
+
function missingTools(tools: GatewayTool[], required: string[]): string[] {
|
|
162
|
+
const names = new Set(tools.map((tool) => tool.name))
|
|
163
|
+
return required.filter((name) => !names.has(resolveToolName(name, tools)))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function withoutVersion(url: string): string {
|
|
167
|
+
const trimmed = url.replace(/\/+$/, '')
|
|
168
|
+
return trimmed.endsWith('/v1') ? trimmed.slice(0, -'/v1'.length) : trimmed
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A gateway that cannot answer for a key cannot be used with one secret. */
|
|
172
|
+
function asIdentityError(error: unknown, baseUrl: string): unknown {
|
|
173
|
+
if (error instanceof TrustGateError && error.status === 404) {
|
|
174
|
+
return new TrustGateError(
|
|
175
|
+
`${baseUrl}/whoami answered 404, so the SDK cannot resolve which consumers ` +
|
|
176
|
+
'this key reaches. Usually the base URL is the wrong address: it is the MCP ' +
|
|
177
|
+
"plane's host on its own, with no consumer path after it — not the " +
|
|
178
|
+
'/<application>/mcp endpoint, and not the LLM plane. Otherwise the gateway ' +
|
|
179
|
+
'predates /whoami and needs upgrading.',
|
|
180
|
+
{ status: 404, cause: error }
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
return error
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* What this application still has to have connected before it can run.
|
|
188
|
+
*
|
|
189
|
+
* `whoami` answers it best, because it also names who has to act. But the field
|
|
190
|
+
* is absent on a gateway too old to send it, and an absent list is not an empty
|
|
191
|
+
* one: taking it for "nothing to connect" is how a batch gets past its own
|
|
192
|
+
* startup check and fails on the first row instead, which is the failure the
|
|
193
|
+
* check exists to prevent. So when it is missing the connections list answers,
|
|
194
|
+
* as it did before `whoami` carried this at all.
|
|
195
|
+
*/
|
|
196
|
+
function blockedUpstreams(upstreams: KeyUpstream[] | undefined, connections: Connection[]): BlockedUpstream[] {
|
|
197
|
+
if (upstreams) {
|
|
198
|
+
return upstreams.filter((upstream) => upstream.blocked)
|
|
199
|
+
}
|
|
200
|
+
return connections
|
|
201
|
+
.filter((connection) => connection.status !== 'connected')
|
|
202
|
+
.map((connection) => ({ server: connection.registry || connection.provider }))
|
|
203
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { TrustGateError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
export type TrustGateConfig = {
|
|
4
|
+
/**
|
|
5
|
+
* The gateway's base URL, without a consumer path:
|
|
6
|
+
* `https://gw.acme.ai`. Defaults to `TRUSTGATE_URL`.
|
|
7
|
+
*/
|
|
8
|
+
baseUrl?: string
|
|
9
|
+
/** The consumer's API key. Defaults to `TRUSTGATE_API_KEY`. */
|
|
10
|
+
apiKey?: string
|
|
11
|
+
/**
|
|
12
|
+
* Slug of the MCP consumer — the one carrying the agent's tools. Defaults
|
|
13
|
+
* to `TRUSTGATE_MCP_CONSUMER`.
|
|
14
|
+
*/
|
|
15
|
+
mcpConsumer?: string
|
|
16
|
+
/**
|
|
17
|
+
* Slug of the LLM consumer — the one in front of the models. Defaults to
|
|
18
|
+
* `TRUSTGATE_LLM_CONSUMER`. It is a different consumer from the MCP one
|
|
19
|
+
* because a consumer has one type; the same API key may be attached to
|
|
20
|
+
* both.
|
|
21
|
+
*/
|
|
22
|
+
llmConsumer?: string
|
|
23
|
+
/** Overrides the fetch implementation. Tests and proxies use this. */
|
|
24
|
+
fetch?: typeof globalThis.fetch
|
|
25
|
+
/** Per-request timeout in milliseconds. Default 30000. */
|
|
26
|
+
timeoutMs?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ResolvedConfig = {
|
|
30
|
+
baseUrl: string
|
|
31
|
+
apiKey: string
|
|
32
|
+
mcpConsumer?: string
|
|
33
|
+
llmConsumer?: string
|
|
34
|
+
fetch: typeof globalThis.fetch
|
|
35
|
+
timeoutMs: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fromEnv(name: string): string | undefined {
|
|
39
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
|
|
40
|
+
return env?.[name]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveConfig(config: TrustGateConfig = {}): ResolvedConfig {
|
|
44
|
+
const baseUrl = (config.baseUrl ?? fromEnv('TRUSTGATE_URL') ?? '').trim().replace(/\/+$/, '')
|
|
45
|
+
const apiKey = (config.apiKey ?? fromEnv('TRUSTGATE_API_KEY') ?? '').trim()
|
|
46
|
+
if (!baseUrl) {
|
|
47
|
+
throw new TrustGateError('baseUrl is required (or set TRUSTGATE_URL)')
|
|
48
|
+
}
|
|
49
|
+
if (!/^https?:\/\//.test(baseUrl)) {
|
|
50
|
+
throw new TrustGateError(`baseUrl must be an http(s) URL, got "${baseUrl}"`)
|
|
51
|
+
}
|
|
52
|
+
if (!apiKey) {
|
|
53
|
+
throw new TrustGateError('apiKey is required (or set TRUSTGATE_API_KEY)')
|
|
54
|
+
}
|
|
55
|
+
const fetchImpl = config.fetch ?? globalThis.fetch
|
|
56
|
+
if (typeof fetchImpl !== 'function') {
|
|
57
|
+
throw new TrustGateError('no fetch available; pass one in config.fetch (Node 18+ has a global)')
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
baseUrl,
|
|
61
|
+
apiKey,
|
|
62
|
+
mcpConsumer: config.mcpConsumer?.trim() || fromEnv('TRUSTGATE_MCP_CONSUMER')?.trim(),
|
|
63
|
+
llmConsumer: config.llmConsumer?.trim() || fromEnv('TRUSTGATE_LLM_CONSUMER')?.trim(),
|
|
64
|
+
fetch: fetchImpl,
|
|
65
|
+
timeoutMs: config.timeoutMs ?? 30_000,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The header the gateway reads the consumer's API key from.
|
|
71
|
+
*
|
|
72
|
+
* It also accepts `x-api-key` and `Authorization: Bearer ag_…`; this one is
|
|
73
|
+
* the unambiguous spelling, so it is the one the SDK sends.
|
|
74
|
+
*/
|
|
75
|
+
export const API_KEY_HEADER = 'X-AG-API-Key'
|
|
76
|
+
|
|
77
|
+
/** The header that names which of the application's end users a call is for. */
|
|
78
|
+
export const END_USER_HEADER = 'X-NeuralTrust-End-User'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ResolvedConfig } from './config.js'
|
|
2
|
+
import { END_USER_HEADER } from './config.js'
|
|
3
|
+
import { requestJSON } from './http.js'
|
|
4
|
+
import { InvalidRequestError } from './errors.js'
|
|
5
|
+
import type { ConnectLink, Connection } from './types.js'
|
|
6
|
+
|
|
7
|
+
type ConnectionPayload = {
|
|
8
|
+
provider: string
|
|
9
|
+
registry?: string
|
|
10
|
+
code?: string
|
|
11
|
+
status: Connection['status']
|
|
12
|
+
account_ref?: string
|
|
13
|
+
expires_at?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ConnectionsPayload = {
|
|
17
|
+
end_user?: string
|
|
18
|
+
actor?: string
|
|
19
|
+
connections?: ConnectionPayload[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type LinkPayload = {
|
|
23
|
+
connect_url: string
|
|
24
|
+
ticket: string
|
|
25
|
+
provider?: string
|
|
26
|
+
expires_at: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function connectionsPath(slug: string, endUser?: string): string {
|
|
30
|
+
const query = endUser ? `?end_user=${encodeURIComponent(endUser)}` : ''
|
|
31
|
+
return `/${encodeURIComponent(slug)}/connections${query}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function listConnections(
|
|
35
|
+
config: ResolvedConfig,
|
|
36
|
+
slug: string,
|
|
37
|
+
endUser?: string,
|
|
38
|
+
signal?: AbortSignal
|
|
39
|
+
): Promise<Connection[]> {
|
|
40
|
+
const { body } = await requestJSON<ConnectionsPayload>(config, 'GET', connectionsPath(slug, endUser), {
|
|
41
|
+
signal,
|
|
42
|
+
})
|
|
43
|
+
return (body?.connections ?? []).map(toConnection)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function createConnectLink(
|
|
47
|
+
config: ResolvedConfig,
|
|
48
|
+
slug: string,
|
|
49
|
+
endUser: string,
|
|
50
|
+
provider: string | undefined,
|
|
51
|
+
signal?: AbortSignal
|
|
52
|
+
): Promise<ConnectLink> {
|
|
53
|
+
const { body } = await requestJSON<LinkPayload>(
|
|
54
|
+
config,
|
|
55
|
+
'POST',
|
|
56
|
+
`/${encodeURIComponent(slug)}/connections/links`,
|
|
57
|
+
{ body: { end_user: endUser, ...(provider ? { provider } : {}) }, headers: { [END_USER_HEADER]: endUser }, signal }
|
|
58
|
+
)
|
|
59
|
+
return {
|
|
60
|
+
connectUrl: body.connect_url,
|
|
61
|
+
ticket: body.ticket,
|
|
62
|
+
provider: body.provider || undefined,
|
|
63
|
+
expiresAt: new Date(body.expires_at),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toConnection(payload: ConnectionPayload): Connection {
|
|
68
|
+
return {
|
|
69
|
+
provider: payload.provider,
|
|
70
|
+
registry: payload.registry || undefined,
|
|
71
|
+
code: payload.code || undefined,
|
|
72
|
+
status: payload.status,
|
|
73
|
+
accountRef: payload.account_ref || undefined,
|
|
74
|
+
expiresAt: payload.expires_at ? new Date(payload.expires_at) : undefined,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The gateway is the authority on what an end-user id may be; this only
|
|
80
|
+
* catches the mistake worth catching locally, which is the empty one — it
|
|
81
|
+
* would otherwise be sent as a header the gateway reads as "no user named"
|
|
82
|
+
* and answer for the wrong actor.
|
|
83
|
+
*/
|
|
84
|
+
export function requireEndUser(endUser: string): string {
|
|
85
|
+
const trimmed = endUser?.trim() ?? ''
|
|
86
|
+
if (!trimmed) {
|
|
87
|
+
throw new InvalidRequestError('an end-user id is required to act for a user')
|
|
88
|
+
}
|
|
89
|
+
return trimmed
|
|
90
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every failure the SDK raises, as a type you can catch.
|
|
3
|
+
*
|
|
4
|
+
* The gateway answers a tool call with a JSON-RPC error and the connections
|
|
5
|
+
* API with `{error, message}`. Both are relayed here as classes rather than
|
|
6
|
+
* status codes, because the useful question is never "what number came back"
|
|
7
|
+
* but "is this mine to fix, my user's, or my admin's".
|
|
8
|
+
*/
|
|
9
|
+
export class TrustGateError extends Error {
|
|
10
|
+
readonly status?: number
|
|
11
|
+
readonly code?: string
|
|
12
|
+
|
|
13
|
+
constructor(message: string, options: { status?: number; code?: string; cause?: unknown } = {}) {
|
|
14
|
+
super(message, { cause: options.cause })
|
|
15
|
+
this.name = new.target.name
|
|
16
|
+
this.status = options.status
|
|
17
|
+
this.code = options.code
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The API key is wrong, or it does not belong to this consumer. */
|
|
22
|
+
export class AuthenticationError extends TrustGateError {}
|
|
23
|
+
|
|
24
|
+
/** The request was malformed — a bad end-user id, an unknown provider. */
|
|
25
|
+
export class InvalidRequestError extends TrustGateError {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The consumer's toolkit does not carry every tool the agent declared.
|
|
29
|
+
*
|
|
30
|
+
* An admin owns that toolkit, so this is not something the caller can fix in
|
|
31
|
+
* code: it is raised at startup, by name, so the agent never reaches a user
|
|
32
|
+
* only to find the tool it was written around is not there.
|
|
33
|
+
*/
|
|
34
|
+
export class MissingToolsError extends TrustGateError {
|
|
35
|
+
constructor(readonly missing: string[], readonly available: string[]) {
|
|
36
|
+
super(
|
|
37
|
+
`the consumer's toolkit is missing ${missing.map((t) => `"${t}"`).join(', ')}. ` +
|
|
38
|
+
`It offers: ${available.length ? available.join(', ') : '(nothing)'}. ` +
|
|
39
|
+
'Ask the admin who owns this application to add them.'
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Servers the application cannot call yet, and who has to fix that.
|
|
46
|
+
*
|
|
47
|
+
* Raised at startup only, for the application handle: nobody is present to
|
|
48
|
+
* follow a connect link once a batch is running, so the run either knows
|
|
49
|
+
* beforehand or fails halfway through. The remedy is never the caller's — an
|
|
50
|
+
* account a whole team rides on is an administrator's to connect, and a
|
|
51
|
+
* per-caller account wants the person this call is for, which is what
|
|
52
|
+
* `forEndUser` is.
|
|
53
|
+
*/
|
|
54
|
+
export class UpstreamNotConnectedError extends TrustGateError {
|
|
55
|
+
constructor(readonly upstreams: BlockedUpstream[]) {
|
|
56
|
+
super(describeBlocked(upstreams))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The server names, for a caller that wants to log or list them. */
|
|
60
|
+
get servers(): string[] {
|
|
61
|
+
return this.upstreams.map((upstream) => upstream.server)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One server from {@link UpstreamNotConnectedError}. */
|
|
66
|
+
export type BlockedUpstream = {
|
|
67
|
+
server: string
|
|
68
|
+
blocked?: 'administrator' | 'end_user'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Says who fixes each server, with the line of code when it is the caller.
|
|
73
|
+
*
|
|
74
|
+
* Read on a terminal at startup, so it is written to be acted on there: the
|
|
75
|
+
* per-user case is the one a developer hits first, and the fix is one call
|
|
76
|
+
* they have not seen yet, so the call is in the message.
|
|
77
|
+
*/
|
|
78
|
+
function describeBlocked(upstreams: BlockedUpstream[]): string {
|
|
79
|
+
const byAdmin = upstreams.filter((upstream) => upstream.blocked === 'administrator')
|
|
80
|
+
const byUser = upstreams.filter((upstream) => upstream.blocked === 'end_user')
|
|
81
|
+
const parts: string[] = []
|
|
82
|
+
if (byUser.length > 0) {
|
|
83
|
+
parts.push(
|
|
84
|
+
`${names(byUser)} ${verb(byUser, 'keeps', 'keep')} one account per user, and ` +
|
|
85
|
+
'connect() runs as the application, which has none there.\n' +
|
|
86
|
+
'\n' +
|
|
87
|
+
'Run as the person the work is for:\n' +
|
|
88
|
+
'\n' +
|
|
89
|
+
" const agent = await tg.forEndUser('user_123')\n" +
|
|
90
|
+
'\n' +
|
|
91
|
+
`or have an administrator set ${names(byUser)} to a shared account in the ` +
|
|
92
|
+
"console (Registry, on the server's instance), and connect() works as it is."
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
if (byAdmin.length > 0) {
|
|
96
|
+
parts.push(
|
|
97
|
+
`${names(byAdmin)} ${verb(byAdmin, 'uses', 'use')} one shared account for every ` +
|
|
98
|
+
'caller, and nobody has connected it yet. An administrator connects it in the ' +
|
|
99
|
+
"console: Registry, on the server's instance, Connect."
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
return parts.length > 0 ? parts.join('\n\n') : `${names(upstreams)} ${verb(upstreams, 'is', 'are')} not connected.`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function names(upstreams: BlockedUpstream[]): string {
|
|
106
|
+
return upstreams.map((upstream) => `"${upstream.server}"`).join(', ')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function verb(upstreams: BlockedUpstream[], one: string, many: string): string {
|
|
110
|
+
return upstreams.length === 1 ? one : many
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* An end user has not connected the account this call needs.
|
|
115
|
+
*
|
|
116
|
+
* The link comes with the error because the gateway mints it there: it is the
|
|
117
|
+
* page to put in front of that user, and it expires.
|
|
118
|
+
*/
|
|
119
|
+
export class ConsentRequiredError extends TrustGateError {
|
|
120
|
+
constructor(
|
|
121
|
+
readonly provider: string,
|
|
122
|
+
readonly connectUrl: string,
|
|
123
|
+
readonly cause_: string,
|
|
124
|
+
message?: string
|
|
125
|
+
) {
|
|
126
|
+
super(message ?? `user consent required for ${provider}: open ${connectUrl}`, { code: 'consent_required' })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A policy on the gateway refused the call. Not retryable. */
|
|
131
|
+
export class PolicyBlockedError extends TrustGateError {}
|
|
132
|
+
|
|
133
|
+
/** The tool is not on this consumer's surface — usually because it just left it. */
|
|
134
|
+
export class ToolNotFoundError extends TrustGateError {
|
|
135
|
+
constructor(readonly tool: string, message?: string) {
|
|
136
|
+
super(message ?? `the gateway does not serve a tool named "${tool}"`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The connect-attempt limiter refused this call. */
|
|
141
|
+
export class RateLimitedError extends TrustGateError {
|
|
142
|
+
constructor(message: string, readonly retryAfterMs?: number) {
|
|
143
|
+
super(message, { status: 429, code: 'rate_limited' })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The gateway could not serve the request and says so. Retryable. */
|
|
148
|
+
export class ServiceUnavailableError extends TrustGateError {}
|
|
149
|
+
|
|
150
|
+
/** The gateway failed on its own account. */
|
|
151
|
+
export class TrustGateServerError extends TrustGateError {}
|
|
152
|
+
|
|
153
|
+
/** The SDK was pointed at a plane this API key does not reach. */
|
|
154
|
+
export class PlaneUnavailableError extends TrustGateError {}
|