@nylorun/runtime 0.28.0-beta → 0.30.0-beta

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 (57) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +1 -1
  3. package/dist/api/components.d.ts +109 -2
  4. package/dist/api/http/define.d.ts +4 -0
  5. package/dist/api/http/define.js +20 -9
  6. package/dist/api/http/routes/agent-manifests.d.ts +1 -1
  7. package/dist/api/http/routes/agent-manifests.js +1 -1
  8. package/dist/api/http/routes/agents.js +6 -6
  9. package/dist/api/http/routes/reads.js +29 -4
  10. package/dist/api/http/routes/tenant.js +5 -2
  11. package/dist/api/http/routes/triggers.js +15 -12
  12. package/dist/api/openapi.js +3 -3
  13. package/dist/gates/egress-credentials.js +2 -13
  14. package/dist/gates/handler.js +2 -1
  15. package/dist/gates/http-tool.js +1 -1
  16. package/dist/gates/in-process.d.ts +9 -0
  17. package/dist/gates/in-process.js +21 -0
  18. package/dist/gates/meter.js +16 -0
  19. package/dist/gates/tenant-vaults.d.ts +1 -1
  20. package/dist/host/config-for.d.ts +2 -0
  21. package/dist/host/config-for.js +1 -0
  22. package/dist/host/main.js +3 -0
  23. package/dist/host/stack-config.d.ts +10 -0
  24. package/dist/host/stack-config.js +36 -0
  25. package/dist/management-openapi.json +492 -80
  26. package/dist/mcp/connect.d.ts +36 -7
  27. package/dist/mcp/connect.js +76 -15
  28. package/dist/mcp/gateway-outcomes.d.ts +14 -0
  29. package/dist/mcp/gateway-outcomes.js +46 -0
  30. package/dist/mcp/pool.js +5 -0
  31. package/dist/model/call-headers.d.ts +15 -0
  32. package/dist/model/call-headers.js +9 -0
  33. package/dist/model/models.d.ts +7 -0
  34. package/dist/model/models.js +3 -0
  35. package/dist/model/pi-model.d.ts +6 -0
  36. package/dist/model/pi-model.js +36 -3
  37. package/dist/openapi.json +331 -215
  38. package/dist/store/postgres/drizzle/0037_model_usage_cost_split.sql +4 -0
  39. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  40. package/dist/store/postgres/reads.js +31 -1
  41. package/dist/store/postgres/schema.d.ts +78 -5
  42. package/dist/store/postgres/schema.js +12 -3
  43. package/dist/tenant/agents.js +7 -3
  44. package/dist/tenant/transcript.d.ts +8 -0
  45. package/dist/tenant/transcript.js +2 -0
  46. package/dist/tenant/types.d.ts +5 -0
  47. package/dist/vault/headers.d.ts +8 -0
  48. package/dist/vault/headers.js +25 -0
  49. package/dist/vault/host-model.js +8 -5
  50. package/dist/vault/service.d.ts +12 -5
  51. package/dist/vault/service.js +43 -6
  52. package/dist/vault/sources.js +22 -1
  53. package/dist/vault/workload-token.d.ts +16 -0
  54. package/dist/vault/workload-token.js +58 -0
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +3 -3
@@ -42,6 +42,8 @@ export interface McpToolPage {
42
42
  readonly annotations?: Readonly<Record<string, unknown>>;
43
43
  }[];
44
44
  readonly nextCursor?: string;
45
+ /** What the server, or a gateway in front of it, says besides the tools (MCP `_meta`). */
46
+ readonly _meta?: Readonly<Record<string, unknown>>;
45
47
  }
46
48
  /**
47
49
  * A `tools/call` answer: the raw `CallToolResult`, with each credential value sent for the call
@@ -90,6 +92,8 @@ export type McpToolFailure = {
90
92
  readonly retryable?: boolean;
91
93
  /** With `credential_rejected`: the server. */
92
94
  readonly server?: string;
95
+ /** With `credential_rejected` of the workload token: the gateway that refused it (#496). */
96
+ readonly gateway?: string;
93
97
  /** How many credential values the message had in it, replaced (R2b C8). */
94
98
  readonly redacted?: number;
95
99
  };
@@ -103,7 +107,8 @@ export type McpToolOutcome = {
103
107
  * How a tool call failed at the transport or the server (R2b C7), as the gate answers it:
104
108
  * - `mcp.unreachable`: never sent (address policy, DNS, refused connection, TLS, a connection
105
109
  * that could not be opened). Calling again may work.
106
- * - `credential_rejected`: the server answered `401` (C1).
110
+ * - `credential_rejected`: the server answered `401` (C1), or the gateway of a credential's `via`
111
+ * reported that the server behind it did (#495).
107
112
  * - `mcp.forbidden`: the server answered `403`.
108
113
  * - `mcp.error`: the server answered a JSON-RPC error; the message has its code.
109
114
  * - `mcp.status`: the server answered another HTTP error status, without a JSON-RPC error.
@@ -121,6 +126,8 @@ export interface McpCallFailure {
121
126
  readonly retryable: boolean;
122
127
  /** With `credential_rejected`: the server. */
123
128
  readonly server?: string;
129
+ /** With `credential_rejected` of the workload token: the gateway that refused it (#496). */
130
+ readonly gateway?: string;
124
131
  /** How many credential values the message had in it, replaced (R2b C8). */
125
132
  readonly redacted?: number;
126
133
  }
@@ -136,27 +143,32 @@ export declare class McpCallFailed extends Error {
136
143
  }
137
144
  /**
138
145
  * A request the server answered `401` (R2b C1): it rejected the vault's credential, or wanted one
139
- * and got none. Never retried, since nothing the gate holds changes between tries (Q4). A tool
146
+ * and got none. Through a credential's `via`, also a gateway's answer reporting that the server
147
+ * behind it did (`gateway-outcomes.ts`, #495). Never retried, since nothing the gate holds changes between tries (Q4). A tool
140
148
  * call that meets it fails with `credential_rejected` for the model to see; at discovery it is the
141
149
  * server's `failed` diagnostic.
142
150
  */
143
151
  export declare class CredentialRejected extends McpCallFailed {
144
152
  readonly server: string;
145
- /** Whether a credential of the session's Environment was sent. */
153
+ /** Whether a credential of the session's Agent was sent. */
146
154
  readonly sent: boolean;
147
155
  /**
148
156
  * The answer's `WWW-Authenticate`, which may name the server's protected-resource metadata
149
157
  * (RFC 9728 `resource_metadata`): a tool preview reads it (R2b C12). Never shown to the model.
150
158
  */
151
159
  readonly challenge?: string | undefined;
160
+ /** The gateway at `via` that refused the Runtime's workload token (#496): an expired or unrotated token. */
161
+ readonly gateway?: string | undefined;
152
162
  constructor(server: string,
153
- /** Whether a credential of the session's Environment was sent. */
163
+ /** Whether a credential of the session's Agent was sent. */
154
164
  sent: boolean,
155
165
  /**
156
166
  * The answer's `WWW-Authenticate`, which may name the server's protected-resource metadata
157
167
  * (RFC 9728 `resource_metadata`): a tool preview reads it (R2b C12). Never shown to the model.
158
168
  */
159
- challenge?: string | undefined);
169
+ challenge?: string | undefined,
170
+ /** The gateway at `via` that refused the Runtime's workload token (#496): an expired or unrotated token. */
171
+ gateway?: string | undefined);
160
172
  }
161
173
  /**
162
174
  * A request its connection can no longer make, refused before it reached a tool: the server
@@ -180,12 +192,27 @@ export declare function unreachable(server: string, error: unknown, chars?: numb
180
192
  * so a server that reads it can run the call once.
181
193
  */
182
194
  export declare const IDEMPOTENCY_META_KEY = "nylorun/idempotency-key";
195
+ /** How a connection reaches its server through a gateway: the credential's routing. */
196
+ export interface GatewayRouting {
197
+ /** Where the server's requests go (the credential's `via`). */
198
+ readonly via: string;
199
+ /** What the gateway puts before each tool name it lists (the credential's `toolPrefix`, #494). */
200
+ readonly toolPrefix?: string;
201
+ }
202
+ /**
203
+ * Where a `tools/list` page names the tools a gateway listed without the credential's
204
+ * `toolPrefix` (#494): kept under their listed names, for discovery's diagnostic.
205
+ */
206
+ export declare const UNPREFIXED_TOOLS_META_KEY = "nylorun/unprefixed-tools";
183
207
  /**
184
208
  * An SDK `Client` as an `McpClient`. Its answers and failures carry no credential value sent
185
209
  * with them (R2b C8), and a failed call is an `McpCallFailed` (C7). A keyed call sends its key in
186
- * `_meta` (R5.5).
210
+ * `_meta` (R5.5). Through a `gateway`, an answer that reports the server refused the credential
211
+ * is `CredentialRejected`, as a `401` is (#495), and a gateway's `toolPrefix` is taken off the
212
+ * tools it lists and put back on the calls (#494): everything after this client names the
213
+ * server's own tools.
187
214
  */
188
- export declare function sdkClient(client: Client, serverName: string, errorChars: number): McpClient;
215
+ export declare function sdkClient(client: Client, serverName: string, errorChars: number, gateway?: GatewayRouting): McpClient;
189
216
  export declare function openMcpServer(input: {
190
217
  server: McpServerManifest;
191
218
  authorize: (url: string) => Promise<AuthorizeResult>;
@@ -225,6 +252,8 @@ export declare function listMcpTools(client: McpClient, input: {
225
252
  omitted: string[];
226
253
  disabled: number;
227
254
  unknownTools: string[];
255
+ /** The tools a gateway listed without the credential's `toolPrefix`, as it named them (#494). */
256
+ unprefixed: string[];
228
257
  }>;
229
258
  /**
230
259
  * One tool call and what it came to. Rejects with `McpConnectionStale` when the connection must
@@ -7,6 +7,7 @@ import { MCP_TOOL_DEFAULTS, mcpToolSettings } from "@nylorun/core/define";
7
7
  import { scrub, scrubValues } from "../redact.js";
8
8
  import { guardedFetch, OutboundFailed, OutboundRefused, } from "../tenant/outbound.js";
9
9
  import { credentialSecrets } from "../vault/headers.js";
10
+ import { gatewayAuthFailure } from "./gateway-outcomes.js";
10
11
  import { hostTuning } from "../tuning.js";
11
12
  /** A tool call that failed with a code the model can act on (R2b C7). */
12
13
  export class McpCallFailed extends Error {
@@ -22,7 +23,7 @@ export class McpCallFailed extends Error {
22
23
  /** The failure the gate answered, as the class the loop's own call would have thrown. */
23
24
  static from(failure) {
24
25
  return failure.code === "credential_rejected" && failure.server !== undefined
25
- ? new CredentialRejected(failure.server, !failure.message.includes("holds none"))
26
+ ? new CredentialRejected(failure.server, !failure.message.includes("holds none"), undefined, failure.gateway)
26
27
  : new McpCallFailed(failure);
27
28
  }
28
29
  /** The failed tool outcome the model sees. */
@@ -34,13 +35,15 @@ export class McpCallFailed extends Error {
34
35
  message: failure.message,
35
36
  retryable: failure.retryable,
36
37
  ...(failure.server === undefined ? {} : { server: failure.server }),
38
+ ...(failure.gateway === undefined ? {} : { gateway: failure.gateway }),
37
39
  ...(failure.redacted ? { redacted: failure.redacted } : {}),
38
40
  };
39
41
  }
40
42
  }
41
43
  /**
42
44
  * A request the server answered `401` (R2b C1): it rejected the vault's credential, or wanted one
43
- * and got none. Never retried, since nothing the gate holds changes between tries (Q4). A tool
45
+ * and got none. Through a credential's `via`, also a gateway's answer reporting that the server
46
+ * behind it did (`gateway-outcomes.ts`, #495). Never retried, since nothing the gate holds changes between tries (Q4). A tool
44
47
  * call that meets it fails with `credential_rejected` for the model to see; at discovery it is the
45
48
  * server's `failed` diagnostic.
46
49
  */
@@ -48,26 +51,33 @@ export class CredentialRejected extends McpCallFailed {
48
51
  server;
49
52
  sent;
50
53
  challenge;
54
+ gateway;
51
55
  constructor(server,
52
- /** Whether a credential of the session's Environment was sent. */
56
+ /** Whether a credential of the session's Agent was sent. */
53
57
  sent,
54
58
  /**
55
59
  * The answer's `WWW-Authenticate`, which may name the server's protected-resource metadata
56
60
  * (RFC 9728 `resource_metadata`): a tool preview reads it (R2b C12). Never shown to the model.
57
61
  */
58
- challenge) {
62
+ challenge,
63
+ /** The gateway at `via` that refused the Runtime's workload token (#496): an expired or unrotated token. */
64
+ gateway) {
59
65
  super({
60
66
  code: "credential_rejected",
61
- message: sent
62
- ? `The MCP server '${server}' answered HTTP 401: it rejected the credential from the session's Environment`
63
- : `The MCP server '${server}' answered HTTP 401: it needs a credential, and the session's Environment holds none for it`,
67
+ message: gateway
68
+ ? `The gateway ${gateway} answered HTTP 401 for the MCP server '${server}': it rejected the Runtime's workload token`
69
+ : sent
70
+ ? `The MCP server '${server}' answered HTTP 401: it rejected the credential from the session's Agent`
71
+ : `The MCP server '${server}' answered HTTP 401: it needs a credential, and the session's Agent holds none for it`,
64
72
  sent: true,
65
73
  retryable: false,
66
74
  server,
75
+ ...(gateway === undefined ? {} : { gateway }),
67
76
  });
68
77
  this.server = server;
69
78
  this.sent = sent;
70
79
  this.challenge = challenge;
80
+ this.gateway = gateway;
71
81
  this.name = "CredentialRejected";
72
82
  }
73
83
  }
@@ -121,17 +131,58 @@ function tracked(call, run, options) {
121
131
  * so a server that reads it can run the call once.
122
132
  */
123
133
  export const IDEMPOTENCY_META_KEY = "nylorun/idempotency-key";
134
+ /**
135
+ * Where a `tools/list` page names the tools a gateway listed without the credential's
136
+ * `toolPrefix` (#494): kept under their listed names, for discovery's diagnostic.
137
+ */
138
+ export const UNPREFIXED_TOOLS_META_KEY = "nylorun/unprefixed-tools";
124
139
  /**
125
140
  * An SDK `Client` as an `McpClient`. Its answers and failures carry no credential value sent
126
141
  * with them (R2b C8), and a failed call is an `McpCallFailed` (C7). A keyed call sends its key in
127
- * `_meta` (R5.5).
142
+ * `_meta` (R5.5). Through a `gateway`, an answer that reports the server refused the credential
143
+ * is `CredentialRejected`, as a `401` is (#495), and a gateway's `toolPrefix` is taken off the
144
+ * tools it lists and put back on the calls (#494): everything after this client names the
145
+ * server's own tools.
128
146
  */
129
- export function sdkClient(client, serverName, errorChars) {
147
+ export function sdkClient(client, serverName, errorChars, gateway) {
148
+ const prefix = gateway?.toolPrefix;
149
+ // The gateway may name the server by the prefix's stem (LiteLLM's `github-` for `github`).
150
+ const keys = prefix === undefined ? [serverName] : [serverName, prefix.replace(/[^A-Za-z0-9]+$/, "")];
151
+ const rejected = (answer) => gateway !== undefined && gatewayAuthFailure(answer, keys);
152
+ // The name the gateway listed for each tool, by the server's own name, and the names it listed
153
+ // without the prefix: a call sends the tool's name as the gateway listed it.
154
+ const listedAs = new Map();
155
+ const unprefixed = new Set();
156
+ const listedName = (name) => prefix === undefined || unprefixed.has(name) ? name : (listedAs.get(name) ?? `${prefix}${name}`);
157
+ const stripped = (page) => {
158
+ if (prefix === undefined)
159
+ return page;
160
+ const missing = [];
161
+ const tools = page.tools.map((tool) => {
162
+ if (typeof tool.name === "string" && tool.name.startsWith(prefix) && tool.name.length > prefix.length) {
163
+ const name = tool.name.slice(prefix.length);
164
+ listedAs.set(name, tool.name);
165
+ return { ...tool, name };
166
+ }
167
+ if (typeof tool.name === "string") {
168
+ unprefixed.add(tool.name);
169
+ missing.push(tool.name);
170
+ }
171
+ return tool;
172
+ });
173
+ return {
174
+ ...page,
175
+ tools,
176
+ ...(missing.length === 0 ? {} : { _meta: { ...page._meta, [UNPREFIXED_TOOLS_META_KEY]: missing } }),
177
+ };
178
+ };
130
179
  return {
131
180
  listTools: (params, options) => tracked(false, async (tracker) => {
132
181
  try {
133
182
  const page = await client.listTools(params, options?.signal ? { signal: options.signal } : undefined);
134
- return scrubbed(page, tracker);
183
+ if (rejected(page))
184
+ throw new CredentialRejected(serverName, true);
185
+ return stripped(scrubbed(page, tracker));
135
186
  }
136
187
  catch (error) {
137
188
  throw scrubError(error, tracker);
@@ -144,15 +195,18 @@ export function sdkClient(client, serverName, errorChars) {
144
195
  return tracked(true, async (tracker) => {
145
196
  const lost = tracker.calling.signal;
146
197
  try {
198
+ const named = { ...params, name: listedName(params.name) };
147
199
  const keyed = options?.key === undefined
148
- ? params
149
- : { ...params, _meta: { [IDEMPOTENCY_META_KEY]: options.key } };
200
+ ? named
201
+ : { ...named, _meta: { [IDEMPOTENCY_META_KEY]: options.key } };
150
202
  const result = (await client.callTool(keyed, undefined, {
151
203
  signal: options?.signal ? AbortSignal.any([options.signal, lost]) : lost,
152
204
  }));
153
205
  // An answer the stream did not count (an `sse` server answers on its event stream).
154
206
  if (Buffer.byteLength(JSON.stringify(result)) > maxResultBytes)
155
207
  throw tooLargeFailure(serverName, maxResultBytes);
208
+ if (result.isError === true && rejected(result))
209
+ throw new CredentialRejected(serverName, true);
156
210
  const answer = scrubValues(result, [...tracker.secrets]);
157
211
  return { result: answer.value, ...(answer.redacted > 0 ? { redacted: answer.redacted } : {}) };
158
212
  }
@@ -202,8 +256,11 @@ export async function openMcpServer(input) {
202
256
  serverInfo: info ? scrubbed({ ...info }, tracker) : undefined,
203
257
  };
204
258
  }, { errorChars });
259
+ const gateway = initial.status === "authorized" && initial.via !== undefined
260
+ ? { via: initial.via, ...(initial.toolPrefix === undefined ? {} : { toolPrefix: initial.toolPrefix }) }
261
+ : undefined;
205
262
  return {
206
- client: sdkClient(client, input.server.name, errorChars),
263
+ client: sdkClient(client, input.server.name, errorChars, gateway),
207
264
  ...(instructions === undefined ? {} : { instructions }),
208
265
  ...(serverInfo === undefined ? {} : { serverInfo }),
209
266
  close: () => client.close(),
@@ -227,11 +284,15 @@ export function serverInstructions(text, secrets) {
227
284
  */
228
285
  export async function listMcpTools(client, input) {
229
286
  const listed = [];
287
+ const unprefixed = [];
230
288
  let cursor;
231
289
  const pages = hostTuning().harness.mcpListPages;
232
290
  for (let page = 0; page < pages; page += 1) {
233
291
  const answer = await client.listTools(cursor ? { cursor } : undefined, input.signal ? { signal: input.signal } : undefined);
234
292
  listed.push(...answer.tools);
293
+ const missing = answer._meta?.[UNPREFIXED_TOOLS_META_KEY];
294
+ if (Array.isArray(missing))
295
+ unprefixed.push(...missing.filter((name) => typeof name === "string"));
235
296
  cursor = answer.nextCursor;
236
297
  if (!cursor)
237
298
  break;
@@ -267,7 +328,7 @@ export async function listMcpTools(client, input) {
267
328
  ...(annotations === undefined || Object.keys(annotations).length === 0 ? {} : { annotations }),
268
329
  });
269
330
  }
270
- return { tools, omitted, disabled, unknownTools };
331
+ return { tools, omitted, disabled, unknownTools, unprefixed };
271
332
  }
272
333
  /**
273
334
  * One tool call and what it came to. Rejects with `McpConnectionStale` when the connection must
@@ -413,7 +474,7 @@ function authorizedFetch(server, endpoint, authorize, policy, errorChars) {
413
474
  tracker.sent = true;
414
475
  if (response.status === 401) {
415
476
  await response.body?.cancel().catch(() => undefined);
416
- throw new CredentialRejected(server.name, result.status === "authorized", response.headers.get("www-authenticate") ?? undefined);
477
+ throw new CredentialRejected(server.name, result.status === "authorized", response.headers.get("www-authenticate") ?? undefined, result.status === "authorized" && result.workload ? endpoint.origin : undefined);
417
478
  }
418
479
  // The server ended the connection's session: the tool never saw the request.
419
480
  if (post && response.status === 404 && headers.has("mcp-session-id")) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A gateway's report that the server behind it refused its credential (#495). A gateway that holds
3
+ * people's tokens may answer an upstream `401` with HTTP 200: LiteLLM lists no tools and names the
4
+ * outcome in the answer's `_meta`, and fails a tool call with `isError` and the upstream error's
5
+ * text. Only answers that came through a credential's `via` are read so: a server the Runtime talks
6
+ * to directly says `401` itself.
7
+ */
8
+ /** The names a gateway may give the server under: the manifest's first, then any others. */
9
+ export type GatewayServerKeys = readonly string[];
10
+ /**
11
+ * Whether a `tools/list` page or a `tools/call` result reports that the server behind the
12
+ * gateway refused the credential. A call's text is read only when the result is an error.
13
+ */
14
+ export declare function gatewayAuthFailure(answer: unknown, keys: GatewayServerKeys): boolean;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A gateway's report that the server behind it refused its credential (#495). A gateway that holds
3
+ * people's tokens may answer an upstream `401` with HTTP 200: LiteLLM lists no tools and names the
4
+ * outcome in the answer's `_meta`, and fails a tool call with `isError` and the upstream error's
5
+ * text. Only answers that came through a credential's `via` are read so: a server the Runtime talks
6
+ * to directly says `401` itself.
7
+ */
8
+ /** LiteLLM's outcome per server it reached: `{ status: "auth_required", http_status: 401 }`. */
9
+ const LITELLM_SERVER_OUTCOMES = "litellm.ai/server_outcomes";
10
+ /** LiteLLM's text for a tool call the upstream server answered `401` (httpx's error message). */
11
+ const LITELLM_UNAUTHORIZED_TEXT = /HTTPStatusError: Client error '401 Unauthorized'/;
12
+ /**
13
+ * Whether a `tools/list` page or a `tools/call` result reports that the server behind the
14
+ * gateway refused the credential. A call's text is read only when the result is an error.
15
+ */
16
+ export function gatewayAuthFailure(answer, keys) {
17
+ if (!isRecord(answer))
18
+ return false;
19
+ const outcome = serverOutcome(answer._meta, keys);
20
+ if (outcome && (outcome.status === "auth_required" || outcome.http_status === 401))
21
+ return true;
22
+ return answer.isError === true && LITELLM_UNAUTHORIZED_TEXT.test(textOf(answer.content));
23
+ }
24
+ /** The gateway's outcome for this server: the entry one of `keys` names, else the only entry. */
25
+ function serverOutcome(meta, keys) {
26
+ if (!isRecord(meta))
27
+ return undefined;
28
+ const outcomes = meta[LITELLM_SERVER_OUTCOMES];
29
+ if (!isRecord(outcomes))
30
+ return undefined;
31
+ for (const key of keys)
32
+ if (isRecord(outcomes[key]))
33
+ return outcomes[key];
34
+ const entries = Object.values(outcomes);
35
+ return entries.length === 1 && isRecord(entries[0]) ? entries[0] : undefined;
36
+ }
37
+ function textOf(content) {
38
+ if (!Array.isArray(content))
39
+ return "";
40
+ return content
41
+ .map((part) => (isRecord(part) && typeof part.text === "string" ? part.text : ""))
42
+ .join("\n");
43
+ }
44
+ function isRecord(value) {
45
+ return !!value && typeof value === "object" && !Array.isArray(value);
46
+ }
package/dist/mcp/pool.js CHANGED
@@ -52,6 +52,11 @@ export class McpPool {
52
52
  ...(listed.unknownTools.length === 0
53
53
  ? []
54
54
  : [`Its tools settings name tools it does not list: ${listed.unknownTools.join(", ")}`]),
55
+ ...(listed.unprefixed.length === 0
56
+ ? []
57
+ : [
58
+ `${listed.unprefixed.length} of its tools lack the gateway's tool prefix and keep the names it listed: ${listed.unprefixed.join(", ")}`,
59
+ ]),
55
60
  ...(tooLong
56
61
  ? [
57
62
  `Its instructions are ${Buffer.byteLength(text)} bytes, more than a record entry holds (the Runtime limit recordEntryBytes, ${input.maxInstructionBytes} bytes), and are left out`,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A custom endpoint's per-call headers (#493): each `callHeaders` entry's value, from the calling
3
+ * Agent, for a gateway that attributes or budgets each call, such as by the Agent's `org` label.
4
+ * A source with no value (a missing or empty label, a session whose owner is no person) sends no
5
+ * header, never an empty one.
6
+ */
7
+ import type { ModelCallHeaderSource } from "@nylorun/core/contracts";
8
+ /** What a model call knows of who makes it, read for the call. */
9
+ export interface ModelCaller {
10
+ readonly agentId: string;
11
+ readonly labels: Readonly<Record<string, string>>;
12
+ /** The session's owner, when it is a person. */
13
+ readonly owner?: string;
14
+ }
15
+ export declare function callHeaders(sources: Readonly<Record<string, ModelCallHeaderSource>>, caller: ModelCaller): Record<string, string>;
@@ -0,0 +1,9 @@
1
+ export function callHeaders(sources, caller) {
2
+ const headers = {};
3
+ for (const [name, source] of Object.entries(sources)) {
4
+ const value = source.from === "agent.label" ? caller.labels[source.key] : source.from === "agent.id" ? caller.agentId : caller.owner;
5
+ if (value)
6
+ headers[name] = value;
7
+ }
8
+ return headers;
9
+ }
@@ -1,4 +1,5 @@
1
1
  import { type AuthContext, type CredentialStore } from "@earendil-works/pi-ai";
2
+ import { type ModelCallHeaderSource } from "@nylorun/core/contracts";
2
3
  export type Selection = Readonly<{
3
4
  provider: string;
4
5
  model: string;
@@ -11,6 +12,10 @@ export type Selection = Readonly<{
11
12
  /** What the endpoint reads (protocol 12); text only unless it says so. */
12
13
  inputs?: readonly string[];
13
14
  compat?: Readonly<Record<string, unknown>>;
15
+ /** Sent on every call (#493). */
16
+ headers?: Readonly<Record<string, string>>;
17
+ /** Resolved per call from the calling Agent (`call-headers.ts`, #493). */
18
+ callHeaders?: Readonly<Record<string, ModelCallHeaderSource>>;
14
19
  }>;
15
20
  }>;
16
21
  /**
@@ -20,4 +25,6 @@ export type Selection = Readonly<{
20
25
  export declare function modelsFor(selection: Selection, credentials: CredentialStore, options?: {
21
26
  environment?: boolean;
22
27
  authContext?: AuthContext;
28
+ /** A custom endpoint's bearer read at each request instead of a stored key: the workload token (#496). */
29
+ bearer?: () => Promise<string>;
23
30
  }): import("@earendil-works/pi-ai").MutableModels;
@@ -30,6 +30,7 @@ export function modelsFor(selection, credentials, options = {}) {
30
30
  ...(selection.custom.compat
31
31
  ? { compat: { ...selection.custom.compat } }
32
32
  : {}),
33
+ ...(selection.custom.headers ? { headers: { ...selection.custom.headers } } : {}),
33
34
  };
34
35
  models.setProvider(createProvider({
35
36
  id: "custom",
@@ -39,6 +40,8 @@ export function modelsFor(selection, credentials, options = {}) {
39
40
  apiKey: {
40
41
  name: "Custom API key",
41
42
  async resolve() {
43
+ if (options.bearer)
44
+ return { type: "api_key", auth: { apiKey: await options.bearer() } };
42
45
  const credential = await credentials.read("custom");
43
46
  if (!credential || credential.type !== "api_key")
44
47
  return undefined;
@@ -3,6 +3,7 @@ import type { RuntimeModelAdapter } from "../contracts.js";
3
3
  import { type RuntimeLimits } from "@nylorun/core/contracts";
4
4
  import { type FileResolver } from "../artifacts/files.js";
5
5
  import type { HostModelSecret } from "../vault/service.js";
6
+ import { type ModelCaller } from "./call-headers.js";
6
7
  export interface ModelPreview {
7
8
  readonly invocationId: string;
8
9
  readonly text: string;
@@ -39,6 +40,11 @@ export interface PiModelOptions {
39
40
  readonly readHostModel?: () => HostModelSecret | undefined | Promise<HostModelSecret | undefined>;
40
41
  readonly writeHostCredential?: (credential: Credential) => void | Promise<void>;
41
42
  readonly settings?: ModelCallSettings;
43
+ /**
44
+ * Who makes the call: read only when the custom endpoint has `callHeaders` (#493), whose values
45
+ * come from it. Without it, a call sends none of them.
46
+ */
47
+ readonly caller?: () => Promise<ModelCaller | undefined>;
42
48
  }
43
49
  /**
44
50
  * Node model adapter. Local provider configuration is read only when invoked.
@@ -7,6 +7,8 @@ import { scrub } from "../redact.js";
7
7
  import { classifyAssistantError, failure } from "./classify.js";
8
8
  import { addedInputsOf, describeModel, selectionOf } from "./descriptor.js";
9
9
  import { modelsFor } from "./models.js";
10
+ import { callHeaders } from "./call-headers.js";
11
+ import { workloadToken, WorkloadTokenUnavailable } from "../vault/workload-token.js";
10
12
  import { NativeBlocks, nativeKindsOf } from "./native-blocks.js";
11
13
  import { projectSecrets } from "./settings.js";
12
14
  /** The model call settings the Runtime limits `limits` set. */
@@ -53,16 +55,37 @@ export function piModel(options = {}) {
53
55
  const stored = await options.readHostModel?.();
54
56
  if (!stored)
55
57
  return failure("auth", "Model provider is not configured. Set it in Studio, or run nylorun tenant models set.", false);
58
+ // The workload token (#496): read from its file now and at each request, never stored.
59
+ let workload;
60
+ if (stored.authType === "workload") {
61
+ try {
62
+ workload = await workloadToken();
63
+ }
64
+ catch (error) {
65
+ if (error instanceof WorkloadTokenUnavailable)
66
+ return failure("auth", error.message, false);
67
+ throw error;
68
+ }
69
+ }
56
70
  const secrets = [
57
71
  ...projectSecrets(root),
58
72
  ...credentialSecrets(stored.credential),
73
+ ...(workload === undefined ? [] : [workload]),
59
74
  ];
60
75
  const redacted = (outcome) => ({
61
76
  ...outcome,
62
77
  message: String(scrub(outcome.message, secrets)),
63
78
  });
64
79
  const selection = selectionOf(stored, call.model?.id);
65
- const registry = modelsFor(selection, hostCredentialStore(stored, options.writeHostCredential), { environment: false, authContext: noAmbientAuth });
80
+ // A custom endpoint's per-call headers (#493), from the calling Agent; static ones are the model's.
81
+ const sources = selection.custom?.callHeaders;
82
+ const caller = sources && options.caller ? await options.caller() : undefined;
83
+ const perCall = sources && caller ? callHeaders(sources, caller) : {};
84
+ const registry = modelsFor(selection, hostCredentialStore(stored, options.writeHostCredential), {
85
+ environment: false,
86
+ authContext: noAmbientAuth,
87
+ ...(workload === undefined ? {} : { bearer: workloadToken }),
88
+ });
66
89
  const found = registry.getModel(selection.provider, selection.model);
67
90
  if (!found)
68
91
  return failure("invalid_request", "Unknown model. Run nylorun tenant models set.", false);
@@ -95,6 +118,7 @@ export function piModel(options = {}) {
95
118
  // The session id keys the provider's prompt cache and session affinity.
96
119
  sessionId: call.executionId,
97
120
  cacheRetention: "short",
121
+ ...(Object.keys(perCall).length > 0 ? { headers: perCall } : {}),
98
122
  timeoutMs: settings.requestTimeoutMs,
99
123
  maxRetries: settings.providerRetries,
100
124
  maxRetryDelayMs: settings.maxRetryDelayMs,
@@ -225,6 +249,13 @@ export function piModel(options = {}) {
225
249
  // pi-ai initializes missing counters to zero; reporting provenance is unknown.
226
250
  tokensReported: null,
227
251
  costKnown: !selection.custom,
252
+ // The cost by token kind, at the catalog's prices: the usage ledger keeps it.
253
+ costSplit: {
254
+ input: response.usage.cost.input,
255
+ cacheRead: response.usage.cost.cacheRead,
256
+ cacheWrite: response.usage.cost.cacheWrite,
257
+ output: response.usage.cost.output,
258
+ },
228
259
  producer: {
229
260
  provider: selected.provider,
230
261
  api: selected.api,
@@ -474,14 +505,16 @@ function idleTimeout(selected, idleTimeoutMs) {
474
505
  }
475
506
  function hostCredentialStore(stored, write) {
476
507
  let current = stored.credential;
508
+ // A workload model stores no credential: pi-ai then asks the provider's `resolve` (`modelsFor`).
509
+ const stores = stored.authType !== "workload";
477
510
  return {
478
511
  async read(providerId) {
479
- if (providerId !== stored.provider)
512
+ if (providerId !== stored.provider || !stores)
480
513
  return undefined;
481
514
  return current;
482
515
  },
483
516
  async list() {
484
- return [{ providerId: stored.provider, type: current.type }];
517
+ return stores ? [{ providerId: stored.provider, type: current.type }] : [];
485
518
  },
486
519
  async modify(providerId, fn) {
487
520
  if (providerId !== stored.provider)