@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/schema.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { JSONSchema } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turning a tool's JSON Schema into what a provider will accept.
|
|
5
|
+
*
|
|
6
|
+
* The gateway relays an upstream server's schema exactly as that server wrote
|
|
7
|
+
* it — that is the honest thing for a gateway to do, and it means the schema
|
|
8
|
+
* can use anything JSON Schema allows. Every provider's function calling
|
|
9
|
+
* accepts a smaller language than that. Translating is therefore the client's
|
|
10
|
+
* job, done here, once, against the format the caller asked for.
|
|
11
|
+
*
|
|
12
|
+
* Two of these conversions lose information, so both are reversible and the
|
|
13
|
+
* original schema is kept: what goes out closed and nullable has to come back
|
|
14
|
+
* open and absent, or the upstream rejects the call it was asked to make.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type StrictResult = {
|
|
18
|
+
schema: JSONSchema
|
|
19
|
+
/** False when the schema uses something strict mode cannot express. */
|
|
20
|
+
strict: boolean
|
|
21
|
+
/** Why not, for the warning the caller gets. */
|
|
22
|
+
reason?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Inlines local `$ref`s.
|
|
27
|
+
*
|
|
28
|
+
* Nothing is lost: a reference and its target describe the same thing. It is
|
|
29
|
+
* separated from the strict pass because every provider needs it and none of
|
|
30
|
+
* them object to the result — which is why this is also the part that could
|
|
31
|
+
* one day move into the gateway.
|
|
32
|
+
*/
|
|
33
|
+
export function inlineRefs(schema: JSONSchema): JSONSchema {
|
|
34
|
+
const defs = {
|
|
35
|
+
...((schema.$defs as Record<string, JSONSchema>) ?? {}),
|
|
36
|
+
...((schema.definitions as Record<string, JSONSchema>) ?? {}),
|
|
37
|
+
}
|
|
38
|
+
const seen = new Set<string>()
|
|
39
|
+
|
|
40
|
+
const walk = (node: unknown): unknown => {
|
|
41
|
+
if (Array.isArray(node)) return node.map(walk)
|
|
42
|
+
if (!isObject(node)) return node
|
|
43
|
+
const ref = node.$ref
|
|
44
|
+
if (typeof ref === 'string') {
|
|
45
|
+
const target = resolveRef(ref, defs)
|
|
46
|
+
// A cycle cannot be inlined; leaving the $ref in place makes the
|
|
47
|
+
// strict pass refuse the tool, which is better than looping.
|
|
48
|
+
if (target && !seen.has(ref)) {
|
|
49
|
+
seen.add(ref)
|
|
50
|
+
const resolved = walk({ ...target, ...omit(node, ['$ref']) })
|
|
51
|
+
seen.delete(ref)
|
|
52
|
+
return resolved
|
|
53
|
+
}
|
|
54
|
+
return node
|
|
55
|
+
}
|
|
56
|
+
const out: Record<string, unknown> = {}
|
|
57
|
+
for (const [key, value] of Object.entries(node)) {
|
|
58
|
+
if (key === '$defs' || key === 'definitions') continue
|
|
59
|
+
out[key] = walk(value)
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return walk(schema) as JSONSchema
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveRef(ref: string, defs: Record<string, JSONSchema>): JSONSchema | undefined {
|
|
68
|
+
const match = /^#\/(?:\$defs|definitions)\/(.+)$/.exec(ref)
|
|
69
|
+
if (!match) return undefined
|
|
70
|
+
return defs[decodeURIComponent(match[1])]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rewrites a schema for OpenAI's strict function calling.
|
|
75
|
+
*
|
|
76
|
+
* Strict buys a guarantee worth having — the model cannot invent an argument —
|
|
77
|
+
* and charges for it in expressiveness: every object closed, every property
|
|
78
|
+
* required, and optionality expressed by accepting null. Schemas that use what
|
|
79
|
+
* strict cannot say are returned untouched with `strict: false`, because a tool
|
|
80
|
+
* the model can still call imperfectly beats a tool it cannot call at all.
|
|
81
|
+
*/
|
|
82
|
+
export function toStrict(schema: JSONSchema): StrictResult {
|
|
83
|
+
const inlined = inlineRefs(schema)
|
|
84
|
+
let reason: string | undefined
|
|
85
|
+
|
|
86
|
+
const walk = (node: unknown): unknown => {
|
|
87
|
+
if (Array.isArray(node)) return node.map(walk)
|
|
88
|
+
if (!isObject(node)) return node
|
|
89
|
+
if ('$ref' in node) {
|
|
90
|
+
reason ??= 'it carries a $ref that does not resolve inside the schema'
|
|
91
|
+
return node
|
|
92
|
+
}
|
|
93
|
+
if ('allOf' in node) {
|
|
94
|
+
reason ??= 'it composes with allOf'
|
|
95
|
+
return node
|
|
96
|
+
}
|
|
97
|
+
if ('prefixItems' in node) {
|
|
98
|
+
reason ??= 'it uses prefixItems (tuple typing)'
|
|
99
|
+
return node
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const out: Record<string, unknown> = {}
|
|
103
|
+
for (const [key, value] of Object.entries(node)) {
|
|
104
|
+
out[key] = key === 'required' ? value : walk(value)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (out.type !== 'object' && !isObject(out.properties)) return out
|
|
108
|
+
|
|
109
|
+
if (out.additionalProperties !== undefined && out.additionalProperties !== false) {
|
|
110
|
+
reason ??= 'it accepts properties that are not in its schema'
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
out.additionalProperties = false
|
|
114
|
+
|
|
115
|
+
const properties = (out.properties as Record<string, JSONSchema>) ?? {}
|
|
116
|
+
const required = new Set(Array.isArray(out.required) ? (out.required as string[]) : [])
|
|
117
|
+
const rewritten: Record<string, JSONSchema> = {}
|
|
118
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
119
|
+
rewritten[name] = required.has(name) ? property : nullable(property)
|
|
120
|
+
}
|
|
121
|
+
out.properties = rewritten
|
|
122
|
+
// Strict wants every property named as required. What used to be
|
|
123
|
+
// optional stays optional in effect, by accepting null.
|
|
124
|
+
out.required = Object.keys(rewritten)
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const rewritten = walk(inlined) as JSONSchema
|
|
129
|
+
return reason ? { schema: inlined, strict: false, reason } : { schema: rewritten, strict: true }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Lets a schema accept null without losing what it already said. */
|
|
133
|
+
function nullable(schema: JSONSchema): JSONSchema {
|
|
134
|
+
if (typeof schema.type === 'string') {
|
|
135
|
+
return { ...schema, type: [schema.type, 'null'] }
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(schema.type)) {
|
|
138
|
+
return schema.type.includes('null') ? schema : { ...schema, type: [...schema.type, 'null'] }
|
|
139
|
+
}
|
|
140
|
+
// No plain type to widen (an enum, an anyOf): offer null beside it.
|
|
141
|
+
return { anyOf: [schema, { type: 'null' }] }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Removes the nulls strict mode asked the model to send.
|
|
146
|
+
*
|
|
147
|
+
* `toStrict` made every optional property nullable so it could be named in
|
|
148
|
+
* `required`. The model takes that literally and sends `null` for the ones it
|
|
149
|
+
* has no value for — and the upstream server, which never agreed to any of
|
|
150
|
+
* this, rejects them. So the arguments are compared against the *original*
|
|
151
|
+
* schema on the way back, and a null it never permitted is dropped rather than
|
|
152
|
+
* forwarded.
|
|
153
|
+
*/
|
|
154
|
+
export function stripInjectedNulls(args: unknown, original: JSONSchema | undefined): unknown {
|
|
155
|
+
if (Array.isArray(args)) {
|
|
156
|
+
const items = original?.items as JSONSchema | undefined
|
|
157
|
+
return args.map((item) => stripInjectedNulls(item, items))
|
|
158
|
+
}
|
|
159
|
+
if (!isObject(args)) return args
|
|
160
|
+
const properties = (original?.properties as Record<string, JSONSchema> | undefined) ?? {}
|
|
161
|
+
const out: Record<string, unknown> = {}
|
|
162
|
+
for (const [key, value] of Object.entries(args)) {
|
|
163
|
+
const property = properties[key]
|
|
164
|
+
if (value === null && !permitsNull(property)) continue
|
|
165
|
+
out[key] = stripInjectedNulls(value, property)
|
|
166
|
+
}
|
|
167
|
+
return out
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function permitsNull(schema: JSONSchema | undefined): boolean {
|
|
171
|
+
// An unknown property is left alone: the upstream may accept keys this
|
|
172
|
+
// schema does not describe, and dropping one would lose a real argument.
|
|
173
|
+
if (!schema) return true
|
|
174
|
+
if (schema.type === 'null') return true
|
|
175
|
+
if (Array.isArray(schema.type) && schema.type.includes('null')) return true
|
|
176
|
+
const anyOf = schema.anyOf ?? schema.oneOf
|
|
177
|
+
if (Array.isArray(anyOf)) {
|
|
178
|
+
return anyOf.some((option) => permitsNull(option as JSONSchema))
|
|
179
|
+
}
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
184
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function omit(source: Record<string, unknown>, keys: string[]): Record<string, unknown> {
|
|
188
|
+
const out: Record<string, unknown> = {}
|
|
189
|
+
for (const [key, value] of Object.entries(source)) {
|
|
190
|
+
if (!keys.includes(key)) out[key] = value
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { TrustGateError } from './errors.js'
|
|
2
|
+
/** Which actor a handle speaks as. The consumer decides this, not the caller. */
|
|
3
|
+
export const Actor = {
|
|
4
|
+
/** The application itself — principal `app:<consumer_id>`. */
|
|
5
|
+
Application: 'application',
|
|
6
|
+
/** One of the application's own end users. */
|
|
7
|
+
EndUser: 'end_user',
|
|
8
|
+
} as const
|
|
9
|
+
export type Actor = (typeof Actor)[keyof typeof Actor]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The tool shapes the SDK can emit.
|
|
13
|
+
*
|
|
14
|
+
* These are model providers, not agent frameworks. A framework brings its own
|
|
15
|
+
* MCP client, so it takes the gateway's URL and lists the tools itself — there
|
|
16
|
+
* is nothing to convert. Conversion only happens when you call a provider's
|
|
17
|
+
* API directly, which is why nothing here is called "langchain".
|
|
18
|
+
*/
|
|
19
|
+
export const ToolFormat = {
|
|
20
|
+
/** OpenAI Responses API — function tools, flattened. */
|
|
21
|
+
OpenAIResponses: 'openai-responses',
|
|
22
|
+
/** OpenAI Chat Completions — function tools, nested under `function`. */
|
|
23
|
+
OpenAIChat: 'openai-chat',
|
|
24
|
+
/** Anthropic Messages — `input_schema`. */
|
|
25
|
+
AnthropicMessages: 'anthropic-messages',
|
|
26
|
+
/** Google Gemini — a single `functionDeclarations` entry. */
|
|
27
|
+
Gemini: 'gemini',
|
|
28
|
+
} as const
|
|
29
|
+
export type ToolFormat = (typeof ToolFormat)[keyof typeof ToolFormat]
|
|
30
|
+
|
|
31
|
+
/** A tool as the gateway serves it, before any provider dialect is applied. */
|
|
32
|
+
export type GatewayTool = {
|
|
33
|
+
name: string
|
|
34
|
+
title?: string
|
|
35
|
+
description?: string
|
|
36
|
+
inputSchema: JSONSchema
|
|
37
|
+
outputSchema?: JSONSchema
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type JSONSchema = Record<string, unknown>
|
|
41
|
+
|
|
42
|
+
/** One upstream account of an actor, as the connections API reports it. */
|
|
43
|
+
export type Connection = {
|
|
44
|
+
provider: string
|
|
45
|
+
registry?: string
|
|
46
|
+
code?: string
|
|
47
|
+
status: 'connected' | 'needs_reconnect' | 'not_connected'
|
|
48
|
+
accountRef?: string
|
|
49
|
+
expiresAt?: Date
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The link an end user opens to connect their own account. */
|
|
53
|
+
export type ConnectLink = {
|
|
54
|
+
connectUrl: string
|
|
55
|
+
ticket: string
|
|
56
|
+
provider?: string
|
|
57
|
+
expiresAt: Date
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Everything a provider's client needs to reach the gateway. */
|
|
61
|
+
export type Endpoint = {
|
|
62
|
+
url: string
|
|
63
|
+
headers: Record<string, string>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A tool call as the SDK understands it, whatever provider asked for it. */
|
|
67
|
+
export type ToolCall = {
|
|
68
|
+
/** The provider's own identifier for this call, echoed back in the result. */
|
|
69
|
+
id: string
|
|
70
|
+
name: string
|
|
71
|
+
arguments: Record<string, unknown>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The exposed name of a tool the caller named.
|
|
76
|
+
*
|
|
77
|
+
* The gateway prefixes every tool with the server it came from, so Linear's
|
|
78
|
+
* `list_issues` is served as `linear_list_issues`. That prefix is the gateway's
|
|
79
|
+
* doing and not the caller's, so a name written without one resolves — as long
|
|
80
|
+
* as exactly one server serves it. Two that do is a genuine question only the
|
|
81
|
+
* caller can answer, and it is asked rather than guessed.
|
|
82
|
+
*
|
|
83
|
+
* A name that matches nothing is returned unchanged, so the error that follows
|
|
84
|
+
* is about the tool rather than about this.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveToolName(name: string, tools: GatewayTool[]): string {
|
|
87
|
+
const names = tools.map((tool) => tool.name)
|
|
88
|
+
if (names.includes(name)) return name
|
|
89
|
+
const matches = names.filter((candidate) => candidate.endsWith(`_${name}`))
|
|
90
|
+
if (matches.length === 1) return matches[0]
|
|
91
|
+
if (matches.length > 1) {
|
|
92
|
+
throw new TrustGateError(
|
|
93
|
+
`"${name}" is served by more than one of this application's servers ` +
|
|
94
|
+
`(${[...matches].sort().join(', ')}). Name the one you mean.`
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
return name
|
|
98
|
+
}
|
package/src/whoami.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ResolvedConfig } from './config.js'
|
|
2
|
+
import { PlaneUnavailableError, TrustGateError } from './errors.js'
|
|
3
|
+
import { requestJSON } from './http.js'
|
|
4
|
+
|
|
5
|
+
/** Whose account an MCP server reads: the one its instance holds, or one per caller. */
|
|
6
|
+
export type UpstreamAccount = 'shared' | 'user'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Who has to act before a server answers a call that runs as the application.
|
|
10
|
+
*
|
|
11
|
+
* `administrator` is an instance whose shared account nobody has connected —
|
|
12
|
+
* no caller can connect it, because it is the account every other caller rides
|
|
13
|
+
* on. `end_user` is an instance that keeps an account per caller, which an
|
|
14
|
+
* application is not: it names the person it acts for, and the account becomes
|
|
15
|
+
* theirs to connect.
|
|
16
|
+
*/
|
|
17
|
+
export type UpstreamBlockedBy = 'administrator' | 'end_user'
|
|
18
|
+
|
|
19
|
+
/** One MCP server the application is bound to, and what it is waiting for. */
|
|
20
|
+
export type KeyUpstream = {
|
|
21
|
+
server: string
|
|
22
|
+
provider?: string
|
|
23
|
+
account: UpstreamAccount
|
|
24
|
+
connected: boolean
|
|
25
|
+
needsReconnect: boolean
|
|
26
|
+
blocked?: UpstreamBlockedBy
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One consumer the key reaches, with the address it is served on. */
|
|
30
|
+
export type KeyConsumer = {
|
|
31
|
+
slug: string
|
|
32
|
+
name?: string
|
|
33
|
+
/** The plane this consumer belongs to: MCP, LLM or A2A. */
|
|
34
|
+
type: string
|
|
35
|
+
active: boolean
|
|
36
|
+
/** Where it answers. Empty when the gateway publishes no host for its plane. */
|
|
37
|
+
url: string
|
|
38
|
+
/**
|
|
39
|
+
* The servers behind it that read a stored account, answered for this key —
|
|
40
|
+
* which is the application itself.
|
|
41
|
+
*
|
|
42
|
+
* `undefined` is not "nothing to connect": it is also what a gateway that
|
|
43
|
+
* could not read the accounts answers, and a server carrying its own
|
|
44
|
+
* credential is never listed. Read `blocked`, never a length.
|
|
45
|
+
*/
|
|
46
|
+
upstreams?: KeyUpstream[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The calling key itself. The secret is never echoed. */
|
|
50
|
+
export type KeyInfo = {
|
|
51
|
+
name?: string
|
|
52
|
+
/** When it retires itself. `undefined` means never. */
|
|
53
|
+
expiresAt?: Date
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Everything the key can say about itself. */
|
|
57
|
+
export type KeyIdentity = {
|
|
58
|
+
gateway: string
|
|
59
|
+
key: KeyInfo
|
|
60
|
+
consumers: KeyConsumer[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type Payload = {
|
|
64
|
+
gateway?: string
|
|
65
|
+
key?: { name?: string; expires_at?: string }
|
|
66
|
+
consumers?: {
|
|
67
|
+
slug: string
|
|
68
|
+
name?: string
|
|
69
|
+
type: string
|
|
70
|
+
active: boolean
|
|
71
|
+
url?: string
|
|
72
|
+
upstreams?: {
|
|
73
|
+
server?: string
|
|
74
|
+
provider?: string
|
|
75
|
+
account?: string
|
|
76
|
+
connected?: boolean
|
|
77
|
+
needs_reconnect?: boolean
|
|
78
|
+
blocked?: string
|
|
79
|
+
}[]
|
|
80
|
+
}[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Asks the key what it reaches, when it dies, and what is not connected yet.
|
|
85
|
+
*
|
|
86
|
+
* It is what lets a client be configured with one secret: the slugs were
|
|
87
|
+
* chosen by whoever created the consumers, and the LLM plane is on a host the
|
|
88
|
+
* MCP URL says nothing about, so both have to come from the gateway. The other
|
|
89
|
+
* two answers are the failures a client would otherwise meet at runtime — an
|
|
90
|
+
* expired key as a 401 mid-run, an unconnected server as a refusal on the
|
|
91
|
+
* first tool call — moved to where something can still be done about them.
|
|
92
|
+
*/
|
|
93
|
+
export async function whoAmI(config: ResolvedConfig, signal?: AbortSignal): Promise<KeyIdentity> {
|
|
94
|
+
const { body } = await requestJSON<Payload>(config, 'GET', '/whoami', { signal })
|
|
95
|
+
return {
|
|
96
|
+
gateway: body?.gateway ?? '',
|
|
97
|
+
key: {
|
|
98
|
+
name: body?.key?.name || undefined,
|
|
99
|
+
expiresAt: parseDate(body?.key?.expires_at),
|
|
100
|
+
},
|
|
101
|
+
consumers: (body?.consumers ?? []).map((consumer) => ({
|
|
102
|
+
slug: consumer.slug,
|
|
103
|
+
name: consumer.name || undefined,
|
|
104
|
+
type: String(consumer.type ?? '').toUpperCase(),
|
|
105
|
+
active: consumer.active !== false,
|
|
106
|
+
url: consumer.url ?? '',
|
|
107
|
+
upstreams: consumer.upstreams?.map((upstream) => ({
|
|
108
|
+
server: upstream.server ?? '',
|
|
109
|
+
provider: upstream.provider || undefined,
|
|
110
|
+
account: upstream.account === 'shared' ? 'shared' : 'user',
|
|
111
|
+
connected: upstream.connected === true,
|
|
112
|
+
needsReconnect: upstream.needs_reconnect === true,
|
|
113
|
+
blocked: blockedBy(upstream.blocked),
|
|
114
|
+
})),
|
|
115
|
+
})),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function blockedBy(raw: string | undefined): UpstreamBlockedBy | undefined {
|
|
120
|
+
return raw === 'administrator' || raw === 'end_user' ? raw : undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseDate(raw: string | undefined): Date | undefined {
|
|
124
|
+
if (!raw) return undefined
|
|
125
|
+
const at = new Date(raw)
|
|
126
|
+
return Number.isNaN(at.getTime()) ? undefined : at
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Picks the one consumer of a plane, or explains why it cannot.
|
|
131
|
+
*
|
|
132
|
+
* A key attached to two consumers of the same type is a legitimate setup that
|
|
133
|
+
* this SDK cannot resolve on its own, so it names them and asks — rather than
|
|
134
|
+
* guessing and running an agent against the wrong surface.
|
|
135
|
+
*/
|
|
136
|
+
export function selectConsumer(
|
|
137
|
+
identity: KeyIdentity,
|
|
138
|
+
plane: 'MCP' | 'LLM',
|
|
139
|
+
configured: string | undefined,
|
|
140
|
+
envName: string
|
|
141
|
+
): KeyConsumer {
|
|
142
|
+
const candidates = identity.consumers.filter((consumer) => consumer.type === plane)
|
|
143
|
+
if (configured) {
|
|
144
|
+
const named = candidates.find((consumer) => consumer.slug === configured)
|
|
145
|
+
if (named) return named
|
|
146
|
+
throw new PlaneUnavailableError(
|
|
147
|
+
`this API key does not reach a ${plane} consumer called "${configured}"` +
|
|
148
|
+
(candidates.length ? `; it reaches ${candidates.map((c) => c.slug).join(', ')}` : '')
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (candidates.length === 0) {
|
|
152
|
+
throw new PlaneUnavailableError(
|
|
153
|
+
`this API key reaches no ${plane} consumer. ` +
|
|
154
|
+
'Ask the admin who owns this application to attach one, or name it explicitly.'
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
if (candidates.length > 1) {
|
|
158
|
+
throw new TrustGateError(
|
|
159
|
+
`this API key reaches several ${plane} consumers (${candidates
|
|
160
|
+
.map((consumer) => consumer.slug)
|
|
161
|
+
.join(', ')}); name the one this agent uses.`
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
const only = candidates[0]
|
|
165
|
+
if (!only.url) {
|
|
166
|
+
throw new PlaneUnavailableError(
|
|
167
|
+
`the gateway publishes no host for its ${plane} plane, so "${only.slug}" has no address. ` +
|
|
168
|
+
`Set ${envName} and a base URL for it, or ask an operator to configure that plane's domain.`
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
return only
|
|
172
|
+
}
|