@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.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +132 -0
  3. package/dist/agent.d.ts +156 -0
  4. package/dist/agent.d.ts.map +1 -0
  5. package/dist/agent.js +216 -0
  6. package/dist/agent.js.map +1 -0
  7. package/dist/client.d.ts +88 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +152 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +44 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +40 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/connections.d.ts +13 -0
  16. package/dist/connections.d.ts.map +1 -0
  17. package/dist/connections.js +46 -0
  18. package/dist/connections.js.map +1 -0
  19. package/dist/errors.d.ts +91 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +144 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/formats.d.ts +44 -0
  24. package/dist/formats.d.ts.map +1 -0
  25. package/dist/formats.js +299 -0
  26. package/dist/formats.js.map +1 -0
  27. package/dist/http.d.ts +23 -0
  28. package/dist/http.d.ts.map +1 -0
  29. package/dist/http.js +94 -0
  30. package/dist/http.js.map +1 -0
  31. package/dist/index.d.ts +10 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +10 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/mcp.d.ts +43 -0
  36. package/dist/mcp.d.ts.map +1 -0
  37. package/dist/mcp.js +175 -0
  38. package/dist/mcp.js.map +1 -0
  39. package/dist/schema.d.ts +52 -0
  40. package/dist/schema.d.ts.map +1 -0
  41. package/dist/schema.js +168 -0
  42. package/dist/schema.js.map +1 -0
  43. package/dist/types.d.ts +78 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +52 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/whoami.d.ts +73 -0
  48. package/dist/whoami.d.ts.map +1 -0
  49. package/dist/whoami.js +79 -0
  50. package/dist/whoami.js.map +1 -0
  51. package/package.json +29 -0
  52. package/src/agent.ts +267 -0
  53. package/src/client.ts +203 -0
  54. package/src/config.ts +78 -0
  55. package/src/connections.ts +90 -0
  56. package/src/errors.ts +154 -0
  57. package/src/formats.ts +362 -0
  58. package/src/http.ts +106 -0
  59. package/src/index.ts +41 -0
  60. package/src/mcp.ts +203 -0
  61. package/src/schema.ts +193 -0
  62. package/src/types.ts +98 -0
  63. 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 {}