@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/CLI.md +11 -6
- package/ENVIRONMENTS.md +2 -1
- package/README.md +105 -4
- package/dist/ai-sdk.d.ts +3 -3
- package/dist/ai-sdk.js +3 -3
- package/dist/cli/env-file.d.ts +22 -0
- package/dist/cli/env-file.js +21 -0
- package/dist/cli/eval.js +21 -6
- package/dist/cli/login.d.ts +1 -1
- package/dist/cli/login.js +9 -4
- package/dist/client.d.ts +31 -3
- package/dist/client.js +199 -7
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/environment/tools.d.ts +2 -2
- package/dist/environment/tools.js +2 -2
- package/dist/environment/types.d.ts +3 -2
- package/dist/experimental-telemetry.d.ts +3 -2
- package/dist/experimental-telemetry.js +3 -2
- package/dist/inline-files.d.ts +10 -0
- package/dist/inline-files.js +86 -0
- package/dist/privacy.js +54 -5
- package/dist/provider-tools.d.ts +39 -0
- package/dist/provider-tools.js +222 -0
- package/dist/tool-definitions.d.ts +20 -0
- package/dist/tool-definitions.js +274 -0
- package/dist/transport.js +4 -2
- package/dist/types.d.ts +76 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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" &&
|
|
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 (
|
|
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`
|
|
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`
|
|
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
|
|
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
|
|
6
|
-
* recording follows `captureContent`. AI SDK 7 applications use `hueTelemetry`
|
|
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
|
|
4
|
-
* recording follows `captureContent`. AI SDK 7 applications use `hueTelemetry`
|
|
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
|
-
|
|
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
|
-
: [
|
|
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
|
-
|
|
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(
|
|
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;
|