@hue-run/sdk 0.8.0 → 0.8.1

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/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash } from "node:crypto";
2
3
  import { context, isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
3
4
  import { defaultTextMapGetter, defaultTextMapSetter } from "@opentelemetry/api";
4
5
  import { SeverityNumber } from "@opentelemetry/api-logs";
@@ -7,10 +8,12 @@ import { LoggerProvider } from "@opentelemetry/sdk-logs";
7
8
  import { TracerProvider } from "@opentelemetry/sdk-trace";
8
9
  import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources";
9
10
  import { HUE_SCOPE } from "./config.js";
11
+ import { hostedServerAddresses, hostedToolActivity, hostedToolProvider } from "./provider-tools.js";
10
12
  import { encodeContent, noopSpan, safeSpan } from "./safety.js";
11
13
  import { createHueTransport, HueExportError } from "./transport.js";
12
14
  import { verifyTrace } from "./receipt.js";
13
15
  import { sdkVersion } from "./version.js";
16
+ import { MAX_FILE_DATA_BYTES } from "./config.js";
14
17
  /**
15
18
  * Thrown by {@link HueClient.checkConnection} and {@link HueClient.verifyTrace} when Hue cannot be
16
19
  * reached, rejects the project key or answers unexpectedly. The message is fixed and safe to log;
@@ -34,12 +37,28 @@ function identifier(value) {
34
37
  value.length > 4096 ||
35
38
  value.includes("\u0000") ||
36
39
  !value.isWellFormed())
37
- throw new TypeError("Session/user identifiers must contain 1–4096 valid characters");
40
+ throw new TypeError("Session/user/workspace identifiers must contain 1–4096 valid characters");
38
41
  return value;
39
42
  }
40
43
  /** A usable metadata label: a non-blank string of at most 256 characters. */
41
44
  function isLabel(value) {
42
- return typeof value === "string" && value.trim() !== "" && value.length <= 256;
45
+ return (typeof value === "string" &&
46
+ value.trim() !== "" &&
47
+ value.length <= 256 &&
48
+ !value.includes("\u0000") &&
49
+ value.isWellFormed());
50
+ }
51
+ /** A source label uses the stricter wire-safe validation without changing existing labels. */
52
+ function isSourceLabel(value) {
53
+ return (typeof value === "string" &&
54
+ value.trim() !== "" &&
55
+ value.length <= 256 &&
56
+ !value.includes("\u0000") &&
57
+ value.isWellFormed());
58
+ }
59
+ /** A label that is also free of NUL and unpaired surrogates, which export would reject. */
60
+ function isTextLabel(value) {
61
+ return isLabel(value) && !value.includes("\u0000") && value.isWellFormed();
43
62
  }
44
63
  /**
45
64
  * Runs `work` exactly once with `active` as OpenTelemetry's current context, so instrumentations
@@ -96,6 +115,7 @@ class ContextualTracer {
96
115
  ...options.attributes,
97
116
  ...(active?.sessionId ? { "gen_ai.conversation.id": active.sessionId } : {}),
98
117
  ...(active?.userId ? { "user.id": active.userId } : {}),
118
+ ...(active?.workspaceId ? { "hue.workspace.id": active.workspaceId } : {}),
99
119
  },
100
120
  }, parent ?? active?.context ?? context.active()), this.failed);
101
121
  }
@@ -238,6 +258,7 @@ export class HueClient {
238
258
  context: options.parentContext ?? inherited?.context ?? context.active(),
239
259
  sessionId: identifier(options.sessionId ?? inherited?.sessionId),
240
260
  userId: identifier(options.userId ?? inherited?.userId),
261
+ workspaceId: identifier(options.workspaceId ?? inherited?.workspaceId),
241
262
  model: inherited?.model,
242
263
  };
243
264
  span = this.storage.run(active, () => this.tracer.startSpan(name, { kind: options.kind ?? SpanKind.INTERNAL, attributes: options.attributes }, active.context));
@@ -301,18 +322,19 @@ export class HueClient {
301
322
  * `options.callId` is recorded as `gen_ai.tool.call.id`, like the Python `call_id=` keyword.
302
323
  * `options.mcp` records the MCP `initialize` `serverInfo` as `mcp.server.name` /
303
324
  * `mcp.server.version` so a generic tool name can be attributed to the server that
304
- * handled it. Pass `client.getServerVersion()`.
325
+ * handled it. Pass `client.getServerVersion()`. `mcp.provider` / `mcp.surface` record the Hue
326
+ * provider and surface as `hue.mcp.provider` / `hue.mcp.surface`.
305
327
  */
306
328
  async tool(name, input, execute, options = {}) {
307
329
  const attributes = {
308
330
  "gen_ai.operation.name": "execute_tool",
309
331
  "gen_ai.tool.name": name,
310
332
  };
311
- const stamp = (key, value) => {
333
+ const stamp = (key, value, valid = isLabel) => {
312
334
  if (value === undefined)
313
335
  return;
314
336
  // A blank or non-string label is omitted and counted; the tool call itself still runs.
315
- if (isLabel(value))
337
+ if (valid(value))
316
338
  attributes[key] = value;
317
339
  else if (this.enabled && !this.closed)
318
340
  this.transport.instrumentationFailure();
@@ -320,6 +342,8 @@ export class HueClient {
320
342
  stamp("gen_ai.tool.call.id", options.callId);
321
343
  stamp("mcp.server.name", options.mcp?.name);
322
344
  stamp("mcp.server.version", options.mcp?.version);
345
+ stamp("hue.mcp.provider", options.mcp?.provider, isSourceLabel);
346
+ stamp("hue.mcp.surface", options.mcp?.surface, isSourceLabel);
323
347
  return this.withSpan(`execute_tool ${name}`, async ({ span }) => {
324
348
  this.setContent(span, "gen_ai.tool.call.arguments", input);
325
349
  const result = await execute();
@@ -337,7 +361,8 @@ export class HueClient {
337
361
  * `gen_ai.request.model` and `gen_ai.provider.name`. The argument order matches `withSpan`. The
338
362
  * handle's `setInput`/`setOutput` record `gen_ai.input.messages` / `gen_ai.output.messages`,
339
363
  * which should use the GenAI semantic-convention message shape; `recordMessages` inside the
340
- * callback inherits the request metadata.
364
+ * callback inherits the request metadata. `options.systemInstructions` and `options.tools` are
365
+ * recorded as `gen_ai.system_instructions` and `gen_ai.tool.definitions`, content like `input`.
341
366
  */
342
367
  async model(model, callback, options) {
343
368
  // A disabled or closed client creates no span, so invalid metadata is not an instrumentation
@@ -357,7 +382,7 @@ export class HueClient {
357
382
  ? `${operation} ${requestModel}`
358
383
  : label(options.name, `${operation} ${requestModel}`);
359
384
  const metadata = { operation, provider, requestModel };
360
- const { sessionId, userId, parentContext, input } = options ?? {};
385
+ const { sessionId, userId, workspaceId, parentContext, input, systemInstructions, tools, } = options ?? {};
361
386
  return this.withSpan(name, (span) => {
362
387
  const handle = {
363
388
  ...span,
@@ -366,12 +391,17 @@ export class HueClient {
366
391
  };
367
392
  if (input !== undefined)
368
393
  handle.setInput(input);
394
+ if (systemInstructions !== undefined)
395
+ this.setContent(span.span, "gen_ai.system_instructions", systemInstructions);
396
+ if (tools !== undefined)
397
+ this.setContent(span.span, "gen_ai.tool.definitions", tools);
369
398
  // recordMessages inside the callback copies this request metadata onto its log record.
370
399
  const store = this.storage.getStore() ?? { context: span.context };
371
400
  return this.storage.run({ ...store, model: metadata }, () => callback(handle));
372
401
  }, {
373
402
  sessionId,
374
403
  userId,
404
+ workspaceId,
375
405
  parentContext,
376
406
  kind: SpanKind.CLIENT,
377
407
  attributes: {
@@ -462,6 +492,8 @@ export class HueClient {
462
492
  body["gen_ai.input.messages"] = messages.input;
463
493
  if (messages.output !== undefined)
464
494
  body["gen_ai.output.messages"] = messages.output;
495
+ if (messages.systemInstructions !== undefined)
496
+ body["gen_ai.system_instructions"] = messages.systemInstructions;
465
497
  // Request metadata is inherited only when the record correlates with the enclosing helper
466
498
  // scope; an unrelated explicit context carries caller-supplied values alone.
467
499
  const enclosing = explicitContext === undefined || explicitContext === store?.context ? store : undefined;
@@ -492,6 +524,166 @@ export class HueClient {
492
524
  this.transport.instrumentationFailure("logs");
493
525
  }
494
526
  }
527
+ /**
528
+ * Records the tools a model provider executed itself while producing `response`, which no
529
+ * `hue.tool()` call saw: OpenAI Responses `mcp_call`, `web_search_call`, `file_search_call` and
530
+ * `code_interpreter_call` items, and Anthropic Messages `mcp_tool_use` / `server_tool_use`
531
+ * blocks with their result blocks. Each becomes an `execute_tool {name}` child span of the active
532
+ * (or given) context with `gen_ai.tool.type` `extension` and `gen_ai.tool.call.id`; MCP calls add
533
+ * `mcp.server.name` (the provider's label, or the `servers` entry for it). Arguments and results
534
+ * follow `captureContent`; a failed call carries `error.type` and ERROR status. An OpenAI
535
+ * `mcp_list_tools` item becomes a `tools/list` child span carrying that server's tools as
536
+ * `gen_ai.tool.definitions`. Call it inside `hue.model()` so the spans nest under the model call
537
+ * and `provider` defaults to its provider; pass `request` to record each server's host as
538
+ * `server.address`. The spans have no duration of their own: the provider ran the tools inside
539
+ * the model request. Unreadable items are skipped and counted; nothing is thrown.
540
+ */
541
+ recordProviderToolCalls(response, options = {}) {
542
+ if (!this.enabled || this.closed)
543
+ return;
544
+ try {
545
+ const store = this.storage.getStore();
546
+ const provider = options.provider ?? hostedToolProvider(store?.model?.provider);
547
+ if (provider === undefined)
548
+ throw new TypeError("Unknown provider for hosted tool calls");
549
+ const parent = options.parentContext ?? store?.context ?? context.active();
550
+ const addresses = hostedServerAddresses(provider, options.request);
551
+ const activity = hostedToolActivity(provider, response);
552
+ if (activity.skipped > 0)
553
+ this.transport.instrumentationFailure("traces", undefined, activity.skipped);
554
+ const server = (label) => {
555
+ const attributes = {};
556
+ if (label === undefined)
557
+ return attributes;
558
+ const info = options.servers?.[label];
559
+ for (const [key, value] of [
560
+ ["mcp.server.name", info?.name ?? label],
561
+ ["mcp.server.version", info?.version],
562
+ ["hue.mcp.provider", info?.provider],
563
+ ["hue.mcp.surface", info?.surface],
564
+ ]) {
565
+ if (value === undefined)
566
+ continue;
567
+ if (isLabel(value))
568
+ attributes[key] = value;
569
+ else
570
+ this.transport.instrumentationFailure();
571
+ }
572
+ const address = addresses.get(label);
573
+ if (address !== undefined)
574
+ attributes["server.address"] = address;
575
+ return attributes;
576
+ };
577
+ const fail = (span, type) => {
578
+ span.setAttribute("error.type", type);
579
+ span.setStatus({ code: SpanStatusCode.ERROR });
580
+ };
581
+ for (const call of activity.calls) {
582
+ const span = this.tracer.startSpan(`execute_tool ${call.name}`, {
583
+ kind: SpanKind.INTERNAL,
584
+ attributes: {
585
+ "gen_ai.operation.name": "execute_tool",
586
+ "gen_ai.tool.name": call.name,
587
+ "gen_ai.tool.type": "extension",
588
+ ...(call.callId === undefined ? {} : { "gen_ai.tool.call.id": call.callId }),
589
+ ...server(call.server),
590
+ },
591
+ }, parent);
592
+ if (call.arguments !== undefined)
593
+ this.setContent(span, "gen_ai.tool.call.arguments", call.arguments);
594
+ if (call.result !== undefined)
595
+ this.setContent(span, "gen_ai.tool.call.result", call.result);
596
+ if (call.errorType !== undefined)
597
+ fail(span, call.errorType);
598
+ span.end();
599
+ }
600
+ for (const listing of activity.listings) {
601
+ const span = this.tracer.startSpan("tools/list", {
602
+ kind: SpanKind.INTERNAL,
603
+ attributes: { "mcp.method.name": "tools/list", ...server(listing.server) },
604
+ }, parent);
605
+ this.setContent(span, "gen_ai.tool.definitions", listing.definitions);
606
+ if (listing.errorType !== undefined)
607
+ fail(span, listing.errorType);
608
+ span.end();
609
+ }
610
+ }
611
+ catch {
612
+ this.transport.instrumentationFailure();
613
+ }
614
+ }
615
+ /**
616
+ * Adds a `hue.file` event to the active (or given) span for a file the work read, received or
617
+ * produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
618
+ * and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
619
+ * never exported. The event is metadata, so it is recorded in both capture modes. An invalid
620
+ * record, or one without an active span, is omitted and counted, never thrown; an invalid name
621
+ * alone is omitted and counted while the rest is recorded.
622
+ */
623
+ recordFile(file, explicitContext) {
624
+ if (!this.enabled || this.closed)
625
+ return;
626
+ try {
627
+ const span = trace.getSpan(explicitContext ?? this.storage.getStore()?.context ?? context.active());
628
+ if (!span?.isRecording())
629
+ throw new Error("File records require an active span");
630
+ const { role, mediaType, data, name } = file;
631
+ if (role !== "input" && role !== "attachment" && role !== "output")
632
+ throw new TypeError("Invalid file role");
633
+ if (!isTextLabel(mediaType))
634
+ throw new TypeError("Invalid media type");
635
+ let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
636
+ let byteSize = file.byteSize;
637
+ if (data !== undefined) {
638
+ const bytes = typeof data === "string"
639
+ ? (() => {
640
+ // Buffer.byteLength measures UTF-8 without allocating the copy that hashing would
641
+ // otherwise require. Reject before Buffer.from/createHash can retain large input.
642
+ if (data.length > MAX_FILE_DATA_BYTES ||
643
+ Buffer.byteLength(data, "utf8") > MAX_FILE_DATA_BYTES)
644
+ throw new RangeError("File data exceeds Hue's 25 MiB limit");
645
+ return Buffer.from(data, "utf8");
646
+ })()
647
+ : data instanceof Uint8Array
648
+ ? data.byteLength <= MAX_FILE_DATA_BYTES
649
+ ? data
650
+ : (() => {
651
+ throw new RangeError("File data exceeds Hue's 25 MiB limit");
652
+ })()
653
+ : undefined;
654
+ if (!bytes)
655
+ throw new TypeError("File data must be bytes or a string");
656
+ const digest = createHash("sha256").update(bytes).digest("hex");
657
+ // A caller-supplied digest or size must describe the same bytes.
658
+ if ((sha256 !== undefined && sha256 !== digest) ||
659
+ (byteSize !== undefined && byteSize !== bytes.byteLength))
660
+ throw new TypeError("File digest or size does not match its data");
661
+ sha256 = digest;
662
+ byteSize = bytes.byteLength;
663
+ }
664
+ if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256))
665
+ throw new TypeError("A file needs a SHA-256 digest or its data");
666
+ if (byteSize !== undefined && (!Number.isSafeInteger(byteSize) || byteSize < 0))
667
+ throw new TypeError("Invalid file size");
668
+ const attributes = {
669
+ "hue.file.sha256": sha256,
670
+ "hue.file.role": role,
671
+ "hue.file.media_type": mediaType,
672
+ };
673
+ if (byteSize !== undefined)
674
+ attributes["hue.file.size"] = byteSize;
675
+ if (this.captureContent && name !== undefined) {
676
+ if (isTextLabel(name))
677
+ attributes["hue.file.name"] = name;
678
+ else
679
+ this.transport.instrumentationFailure();
680
+ }
681
+ span.addEvent("hue.file", attributes);
682
+ }
683
+ catch {
684
+ this.transport.instrumentationFailure();
685
+ }
686
+ }
495
687
  setContent(span, key, value) {
496
688
  if (!this.enabled || this.closed || !this.captureContent)
497
689
  return;
package/dist/config.d.ts CHANGED
@@ -3,6 +3,8 @@ export declare const MAX_BODY_BYTES: number;
3
3
  export declare const MAX_CONTENT_BYTES: number;
4
4
  /** Instrumentation scope of the client's own tracer and logger. */
5
5
  export declare const HUE_SCOPE = "@hue-run/sdk";
6
+ /** Maximum bytes accepted when recordFile hashes caller-provided data locally. */
7
+ export declare const MAX_FILE_DATA_BYTES: number;
6
8
  /** Loopback hostnames that may use plain HTTP without opting in. */
7
9
  export declare function isLoopbackHost(hostname: string): boolean;
8
10
  /** True when a validated origin exports over plain HTTP to a host other than loopback. */
package/dist/config.js CHANGED
@@ -2,6 +2,8 @@ export const MAX_BODY_BYTES = 1024 * 1024;
2
2
  export const MAX_CONTENT_BYTES = 256 * 1024;
3
3
  /** Instrumentation scope of the client's own tracer and logger. */
4
4
  export const HUE_SCOPE = "@hue-run/sdk";
5
+ /** Maximum bytes accepted when recordFile hashes caller-provided data locally. */
6
+ export const MAX_FILE_DATA_BYTES = 25 * 1024 * 1024;
5
7
  /** Loopback hostnames that may use plain HTTP without opting in. */
6
8
  export function isLoopbackHost(hostname) {
7
9
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
@@ -29,7 +29,7 @@ export interface BindEnvironmentToolsOptions {
29
29
  /**
30
30
  * Binds a run's generated catalog to plain local callables without changing the agent
31
31
  * framework. Each call is recorded through {@link HueClient.tool}. Catalog entries that
32
- * include `mcp` stamp `mcp.server.name` / `mcp.server.version` so a generic verb is
33
- * attributed to that MCP server.
32
+ * include `mcp` stamp `mcp.server.name` / `mcp.server.version` and `hue.mcp.provider` /
33
+ * `hue.mcp.surface` so a generic verb is attributed to that MCP server and Hue surface.
34
34
  */
35
35
  export declare function bindEnvironmentTools(options: BindEnvironmentToolsOptions): Record<string, EnvironmentTool>;
@@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto";
2
2
  /**
3
3
  * Binds a run's generated catalog to plain local callables without changing the agent
4
4
  * framework. Each call is recorded through {@link HueClient.tool}. Catalog entries that
5
- * include `mcp` stamp `mcp.server.name` / `mcp.server.version` so a generic verb is
6
- * attributed to that MCP server.
5
+ * include `mcp` stamp `mcp.server.name` / `mcp.server.version` and `hue.mcp.provider` /
6
+ * `hue.mcp.surface` so a generic verb is attributed to that MCP server and Hue surface.
7
7
  */
8
8
  export function bindEnvironmentTools(options) {
9
9
  const tools = {};
@@ -252,8 +252,9 @@ export interface ActionDefinition {
252
252
  /** Generated input contract. */
253
253
  inputSchema: ActionSchema;
254
254
  /**
255
- * MCP `initialize` identity when this action is served by one MCP surface.
256
- * Bound environment tools record it on the tool span as `mcp.server.name`.
255
+ * MCP `initialize` identity and Hue provider/surface when this action is served by one MCP
256
+ * surface. Bound environment tools record it on the tool span as `mcp.server.name`,
257
+ * `mcp.server.version`, `hue.mcp.provider` and `hue.mcp.surface`.
257
258
  */
258
259
  mcp?: McpServerInfo;
259
260
  }
@@ -2,7 +2,8 @@ import type { HueClient } from "./client.js";
2
2
  import type { ExperimentalTelemetrySettings } from "./types.js";
3
3
  /**
4
4
  * Per-call telemetry for AI SDK 6: pass as `experimental_telemetry`. Spans are created with Hue's
5
- * tracer, so they parent under `withSpan` and inherit session/user identifiers, and prompt/response
6
- * recording follows `captureContent`. AI SDK 7 applications use `hueTelemetry` from `@hue-run/sdk/ai-sdk`.
5
+ * tracer, so they parent under `withSpan` and inherit session, user and workspace identifiers, and
6
+ * prompt/response recording follows `captureContent`. AI SDK 7 applications use `hueTelemetry`
7
+ * from `@hue-run/sdk/ai-sdk`.
7
8
  */
8
9
  export declare function hueExperimentalTelemetry(hue: HueClient): ExperimentalTelemetrySettings;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Per-call telemetry for AI SDK 6: pass as `experimental_telemetry`. Spans are created with Hue's
3
- * tracer, so they parent under `withSpan` and inherit session/user identifiers, and prompt/response
4
- * recording follows `captureContent`. AI SDK 7 applications use `hueTelemetry` from `@hue-run/sdk/ai-sdk`.
3
+ * tracer, so they parent under `withSpan` and inherit session, user and workspace identifiers, and
4
+ * prompt/response recording follows `captureContent`. AI SDK 7 applications use `hueTelemetry`
5
+ * from `@hue-run/sdk/ai-sdk`.
5
6
  */
6
7
  export function hueExperimentalTelemetry(hue) {
7
8
  return {
@@ -0,0 +1,10 @@
1
+ /** Inline file content larger than this many UTF-8 bytes is exported as its digest instead. */
2
+ export declare const INLINE_FILE_LIMIT: number;
3
+ /**
4
+ * Replaces inline files longer than {@link INLINE_FILE_LIMIT} in a recorded message attribute
5
+ * with their SHA-256 and byte size, so a span that inlines a large file exports the file's
6
+ * identity instead of being rejected for its size. The part keeps its other fields (`type`,
7
+ * `mime_type`, `modality`, `mediaType`, …). Other attributes, shorter messages and values that
8
+ * are not JSON are returned unchanged.
9
+ */
10
+ export declare function hashInlineFiles(key: string, value: unknown): unknown;
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto";
2
+ import { MAX_BODY_BYTES } from "./config.js";
3
+ /** Inline file content larger than this many UTF-8 bytes is exported as its digest instead. */
4
+ export const INLINE_FILE_LIMIT = 64 * 1024;
5
+ const MAX_INLINE_FILE_TEXT = 8 * MAX_BODY_BYTES;
6
+ /** Message attributes whose JSON can inline files: GenAI blob parts and AI SDK 6 file parts. */
7
+ const messageKeys = new Set([
8
+ "gen_ai.input.messages",
9
+ "gen_ai.output.messages",
10
+ "ai.prompt.messages",
11
+ ]);
12
+ /** Strict base64: alphabet characters only, padded to a multiple of four. */
13
+ const base64 = /^[A-Za-z0-9+/]*={0,2}$/;
14
+ const base64DataUrl = /^data:[^,]*;base64,/;
15
+ function binaryMimeType(value) {
16
+ return (typeof value === "string" &&
17
+ !/^text\//i.test(value) &&
18
+ value.toLowerCase() !== "application/json");
19
+ }
20
+ /**
21
+ * The bytes an inline file part carries: base64 (plain or as a `data:` URL) is decoded, and
22
+ * anything else, such as a text file's content, is taken as UTF-8.
23
+ */
24
+ function fileBytes(content, mimeType) {
25
+ const prefix = base64DataUrl.exec(content)?.[0].length ?? 0;
26
+ const payload = content.slice(prefix);
27
+ return (prefix > 0 || binaryMimeType(mimeType)) &&
28
+ payload.length % 4 === 0 &&
29
+ base64.test(payload)
30
+ ? Buffer.from(payload, "base64")
31
+ : Buffer.from(content, "utf8");
32
+ }
33
+ /** The key holding a part's inline content: GenAI `blob` parts and AI SDK 6 `file` parts. */
34
+ function contentKey(part) {
35
+ return part.type === "blob" ? "content" : part.type === "file" ? "data" : undefined;
36
+ }
37
+ function hashNode(value, state, depth) {
38
+ if (depth > 256)
39
+ throw new Error("Message exceeds the supported nesting limit");
40
+ if (Array.isArray(value))
41
+ return value.map((item) => hashNode(item, state, depth + 1));
42
+ if (value === null || typeof value !== "object")
43
+ return value;
44
+ const part = value;
45
+ const key = contentKey(part);
46
+ const inline = key === undefined ? undefined : part[key];
47
+ const mimeType = part.mime_type ?? part.mediaType;
48
+ if (key !== undefined && typeof inline === "string") {
49
+ const bytes = fileBytes(inline, mimeType);
50
+ if (bytes.byteLength > INLINE_FILE_LIMIT) {
51
+ const { [key]: _omitted, ...rest } = part;
52
+ state.changed = true;
53
+ return {
54
+ ...rest,
55
+ sha256: createHash("sha256").update(bytes).digest("hex"),
56
+ size: bytes.byteLength,
57
+ };
58
+ }
59
+ }
60
+ return Object.fromEntries(Object.entries(part).map(([name, item]) => [name, hashNode(item, state, depth + 1)]));
61
+ }
62
+ /**
63
+ * Replaces inline files longer than {@link INLINE_FILE_LIMIT} in a recorded message attribute
64
+ * with their SHA-256 and byte size, so a span that inlines a large file exports the file's
65
+ * identity instead of being rejected for its size. The part keeps its other fields (`type`,
66
+ * `mime_type`, `modality`, `mediaType`, …). Other attributes, shorter messages and values that
67
+ * are not JSON are returned unchanged.
68
+ */
69
+ export function hashInlineFiles(key, value) {
70
+ if (!messageKeys.has(key) ||
71
+ typeof value !== "string" ||
72
+ Buffer.byteLength(value, "utf8") <= INLINE_FILE_LIMIT ||
73
+ Buffer.byteLength(value, "utf8") > MAX_INLINE_FILE_TEXT ||
74
+ !(value.includes('"blob"') || value.includes('"file"')))
75
+ return value;
76
+ try {
77
+ const parsed = JSON.parse(value);
78
+ const state = { changed: false };
79
+ const hashed = hashNode(parsed, state, 0);
80
+ return state.changed ? JSON.stringify(hashed) : value;
81
+ }
82
+ catch {
83
+ // Not JSON, or nested too deeply to inspect: export decides the value's fate as before.
84
+ return value;
85
+ }
86
+ }
package/dist/privacy.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { SpanStatusCode } from "@opentelemetry/api";
1
2
  import { resourceFromAttributes } from "@opentelemetry/resources";
2
3
  import { MAX_BODY_BYTES, MAX_CONTENT_BYTES } from "./config.js";
4
+ import { scrubToolCredentials, withToolCatalogSummary } from "./tool-definitions.js";
5
+ import { hashInlineFiles } from "./inline-files.js";
3
6
  /** Attribute keys (and their dotted children) removed in metadata-only mode. */
4
7
  export const contentPrefixes = [
5
8
  "gen_ai.input.messages",
@@ -95,9 +98,16 @@ function redactValue(value, path, options, budget, depth = 0) {
95
98
  return value;
96
99
  }
97
100
  function attributes(source, options, path, budget) {
98
- return Object.fromEntries(Object.entries(source).flatMap(([key, value]) => !options.captureContent && isContentKey(key)
101
+ // Metadata-only export summarizes the tool definitions it removes by name and digest.
102
+ const summarized = options.captureContent ? source : withToolCatalogSummary(source);
103
+ return Object.fromEntries(Object.entries(summarized).flatMap(([key, value]) => !options.captureContent && isContentKey(key)
99
104
  ? []
100
- : [[key, redactValue(value, `${path}.${key}`, options, budget)]]));
105
+ : [
106
+ [
107
+ key,
108
+ redactValue(scrubToolCredentials(key, hashInlineFiles(key, value)), `${path}.${key}`, options, budget),
109
+ ],
110
+ ]));
101
111
  }
102
112
  function redactResource(resource, options, cache, budget) {
103
113
  let result = cache.get(resource);
@@ -107,8 +117,43 @@ function redactResource(resource, options, cache, budget) {
107
117
  }
108
118
  return result;
109
119
  }
120
+ /**
121
+ * Server identity and failure of an AI SDK 7 provider-executed (`extension`) tool span, read from
122
+ * its recorded result before content is stripped. OpenAI hosted MCP results carry
123
+ * `{ type: "call", serverLabel, name, arguments, output?, error? }`; nothing else names the server.
124
+ */
125
+ function hostedMcpCall(attributes) {
126
+ const result = attributes["gen_ai.tool.call.result"];
127
+ if (attributes["gen_ai.tool.type"] !== "extension" || typeof result !== "string")
128
+ return { failed: false };
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(result);
132
+ }
133
+ catch {
134
+ return { failed: false };
135
+ }
136
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
137
+ return { failed: false };
138
+ const { type, serverLabel, error } = parsed;
139
+ if (type !== "call")
140
+ return { failed: false };
141
+ const valid = typeof serverLabel === "string" &&
142
+ serverLabel.trim() !== "" &&
143
+ serverLabel.length <= 256 &&
144
+ !serverLabel.includes("\u0000") &&
145
+ serverLabel.isWellFormed();
146
+ return { ...(valid ? { serverName: serverLabel } : {}), failed: error != null };
147
+ }
110
148
  export function redactSpan(span, options, cache) {
111
149
  const budget = { bytes: 0, nodes: 0 };
150
+ // Derived before metadata-only stripping so the identity survives without the result itself.
151
+ const hosted = hostedMcpCall(span.attributes);
152
+ const source = {
153
+ ...(hosted.serverName === undefined ? {} : { "mcp.server.name": hosted.serverName }),
154
+ ...(hosted.failed ? { "error.type": "mcp_error" } : {}),
155
+ ...span.attributes,
156
+ };
112
157
  return {
113
158
  name: span.name,
114
159
  kind: span.kind,
@@ -119,12 +164,16 @@ export function redactSpan(span, options, cache) {
119
164
  duration: span.duration,
120
165
  ended: span.ended,
121
166
  status: {
122
- code: span.status.code,
123
- ...(options.captureContent && span.status.message !== undefined
167
+ code: hosted.failed && span.status.code === SpanStatusCode.UNSET
168
+ ? SpanStatusCode.ERROR
169
+ : span.status.code,
170
+ ...(options.captureContent &&
171
+ span.status.message !== undefined &&
172
+ !(hosted.failed && span.status.code === SpanStatusCode.UNSET)
124
173
  ? { message: String(redactValue(span.status.message, "status.message", options, budget)) }
125
174
  : {}),
126
175
  },
127
- attributes: attributes(span.attributes, options, "attributes", budget),
176
+ attributes: attributes(source, options, "attributes", budget),
128
177
  events: span.events
129
178
  .filter((event) => options.captureContent ||
130
179
  !/^gen_ai\.(?:system|user|assistant|tool|choice)/.test(event.name))
@@ -0,0 +1,39 @@
1
+ import type { HostedToolProvider } from "./types.js";
2
+ /** One provider-executed tool call. `arguments` and `result` are content. */
3
+ export interface HostedToolCall {
4
+ name: string;
5
+ callId?: string;
6
+ /** The provider's label (OpenAI `server_label`) or name (Anthropic `server_name`) for the MCP server. */
7
+ server?: string;
8
+ arguments?: unknown;
9
+ result?: unknown;
10
+ /** Low-cardinality failure marker for `error.type`; absent when the call succeeded. */
11
+ errorType?: string;
12
+ }
13
+ /** One `mcp_list_tools` result: the tools a hosted MCP server offered. */
14
+ export interface HostedToolListing {
15
+ server: string;
16
+ /** Tool definitions in the OpenTelemetry GenAI shape (`type`, `name`, `description`, `parameters`). */
17
+ definitions: Record<string, unknown>[];
18
+ errorType?: string;
19
+ }
20
+ export interface HostedToolActivity {
21
+ calls: HostedToolCall[];
22
+ listings: HostedToolListing[];
23
+ /** Items that looked like hosted calls but could not be read. */
24
+ skipped: number;
25
+ }
26
+ /**
27
+ * Reads the hosted tool calls out of a provider response: the `output` items of an OpenAI
28
+ * Responses API response, or the `content` blocks of an Anthropic Messages API response. An array
29
+ * is taken as those items directly. Anything else yields no calls.
30
+ */
31
+ export declare function hostedToolActivity(provider: HostedToolProvider, response: unknown): HostedToolActivity;
32
+ /**
33
+ * The host of each hosted MCP server's URL, by label, read from the request that produced the
34
+ * response: OpenAI `tools[].server_url` by `server_label`, Anthropic `mcp_servers[].url` by `name`.
35
+ * Nothing else in the request is read.
36
+ */
37
+ export declare function hostedServerAddresses(provider: HostedToolProvider, request: unknown): Map<string, string>;
38
+ /** The provider a `model()` call named, when this module can read its responses. */
39
+ export declare function hostedToolProvider(value: unknown): HostedToolProvider | undefined;