@struct-ai/sdk 0.3.17 → 0.4.2

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 (54) hide show
  1. package/README.md +62 -17
  2. package/dist/commonjs/context.d.ts +19 -0
  3. package/dist/commonjs/context.js +58 -0
  4. package/dist/commonjs/core.js +104 -8
  5. package/dist/commonjs/events.d.ts +17 -6
  6. package/dist/commonjs/events.js +82 -59
  7. package/dist/commonjs/genai-content.d.ts +52 -0
  8. package/dist/commonjs/genai-content.js +143 -0
  9. package/dist/commonjs/instrument.d.ts +47 -0
  10. package/dist/commonjs/instrument.js +158 -0
  11. package/dist/commonjs/integrations/anthropic-content.js +18 -6
  12. package/dist/commonjs/integrations/anthropic.d.ts +6 -1
  13. package/dist/commonjs/integrations/anthropic.js +113 -125
  14. package/dist/commonjs/integrations/index.js +8 -0
  15. package/dist/commonjs/integrations/langchain-callback.d.ts +3 -0
  16. package/dist/commonjs/integrations/langchain-callback.js +84 -6
  17. package/dist/commonjs/integrations/langchain-content.js +1 -1
  18. package/dist/commonjs/integrations/openai-content.d.ts +34 -0
  19. package/dist/commonjs/integrations/openai-content.js +375 -0
  20. package/dist/commonjs/integrations/openai.d.ts +39 -0
  21. package/dist/commonjs/integrations/openai.js +305 -0
  22. package/dist/commonjs/semconv.d.ts +1 -0
  23. package/dist/commonjs/semconv.js +1 -0
  24. package/dist/commonjs/truncation.d.ts +29 -0
  25. package/dist/commonjs/truncation.js +184 -10
  26. package/dist/commonjs/version.d.ts +1 -1
  27. package/dist/commonjs/version.js +1 -1
  28. package/dist/esm/context.d.ts +19 -0
  29. package/dist/esm/context.js +56 -0
  30. package/dist/esm/core.js +104 -8
  31. package/dist/esm/events.d.ts +17 -6
  32. package/dist/esm/events.js +82 -61
  33. package/dist/esm/genai-content.d.ts +52 -0
  34. package/dist/esm/genai-content.js +137 -0
  35. package/dist/esm/instrument.d.ts +47 -0
  36. package/dist/esm/instrument.js +155 -0
  37. package/dist/esm/integrations/anthropic-content.js +19 -7
  38. package/dist/esm/integrations/anthropic.d.ts +6 -1
  39. package/dist/esm/integrations/anthropic.js +112 -126
  40. package/dist/esm/integrations/index.js +8 -0
  41. package/dist/esm/integrations/langchain-callback.d.ts +3 -0
  42. package/dist/esm/integrations/langchain-callback.js +85 -7
  43. package/dist/esm/integrations/langchain-content.js +1 -1
  44. package/dist/esm/integrations/openai-content.d.ts +34 -0
  45. package/dist/esm/integrations/openai-content.js +360 -0
  46. package/dist/esm/integrations/openai.d.ts +39 -0
  47. package/dist/esm/integrations/openai.js +296 -0
  48. package/dist/esm/semconv.d.ts +1 -0
  49. package/dist/esm/semconv.js +1 -0
  50. package/dist/esm/truncation.d.ts +29 -0
  51. package/dist/esm/truncation.js +182 -10
  52. package/dist/esm/version.d.ts +1 -1
  53. package/dist/esm/version.js +1 -1
  54. package/package.json +8 -2
@@ -0,0 +1,305 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports._setChatRequestAttrsForTest = exports._detectProviderForTest = void 0;
4
+ exports.patch = patch;
5
+ exports.unpatch = unpatch;
6
+ exports.wrapCreate = wrapCreate;
7
+ exports._wrapCreateForTest = _wrapCreateForTest;
8
+ exports._setActivePatchCtxForTest = _setActivePatchCtxForTest;
9
+ exports._collectResponsesClassesForTest = _collectResponsesClassesForTest;
10
+ const api_1 = require("@opentelemetry/api");
11
+ const context_js_1 = require("../context.js");
12
+ const events_js_1 = require("../events.js");
13
+ const genai_content_js_1 = require("../genai-content.js");
14
+ const instrument_js_1 = require("../instrument.js");
15
+ const semconv_js_1 = require("../semconv.js");
16
+ const openai_content_js_1 = require("./openai-content.js");
17
+ const STRUCT_WRAPPED = Symbol.for("struct.wrapped");
18
+ const STRUCT_ORIGINAL = Symbol.for("struct.original");
19
+ /**
20
+ * Mutable reference updated on each patch() call — wrappers dereference it at
21
+ * call time, so the most recently initialized SDK wins (matches the singleton
22
+ * production model + keeps multi-instance tests working). Mirrors anthropic.ts.
23
+ */
24
+ const activePatchCtx = { value: undefined };
25
+ function childParentContext() {
26
+ const agentSpan = (0, context_js_1.getAgentSpan)();
27
+ return agentSpan
28
+ ? api_1.trace.setSpan(api_1.context.active(), agentSpan)
29
+ : api_1.context.active();
30
+ }
31
+ async function patch(sdk) {
32
+ const openaiMod = (await import("openai"));
33
+ activePatchCtx.value = {
34
+ tracer: sdk.getTracer("struct-sdk-openai"),
35
+ sdk,
36
+ logger: sdk.emitEvents ? sdk.getLogger("struct-sdk-openai") : undefined,
37
+ };
38
+ const classes = collectResponsesClasses(openaiMod);
39
+ for (const cls of classes) {
40
+ wrapMethod(cls.prototype, "create");
41
+ }
42
+ }
43
+ async function unpatch() {
44
+ activePatchCtx.value = undefined;
45
+ try {
46
+ const openaiMod = (await import("openai"));
47
+ const classes = collectResponsesClasses(openaiMod);
48
+ for (const cls of classes) {
49
+ restoreMethod(cls.prototype, "create");
50
+ }
51
+ }
52
+ catch {
53
+ /* not installed */
54
+ }
55
+ }
56
+ /**
57
+ * Collect the `Responses` class from the openai module. The default export
58
+ * (`OpenAI`) exposes `OpenAI.Responses` as a static (client.js assigns it in
59
+ * both v5 and v6). Older versions without the Responses API expose no such
60
+ * static → returns []; patch() becomes a graceful no-op.
61
+ */
62
+ function collectResponsesClasses(mod) {
63
+ const classes = [];
64
+ const OpenAI = mod.default ?? mod.OpenAI;
65
+ if (typeof OpenAI === "function") {
66
+ const ctor = OpenAI;
67
+ if (typeof ctor.Responses === "function") {
68
+ classes.push(ctor.Responses);
69
+ }
70
+ }
71
+ return classes;
72
+ }
73
+ function wrapMethod(proto, methodName) {
74
+ const original = proto[methodName];
75
+ if (typeof original !== "function")
76
+ return;
77
+ const existing = original;
78
+ if (existing[STRUCT_WRAPPED])
79
+ return;
80
+ const wrapper = wrapCreate(original);
81
+ wrapper[STRUCT_WRAPPED] = true;
82
+ wrapper[STRUCT_ORIGINAL] = original;
83
+ proto[methodName] = wrapper;
84
+ }
85
+ function restoreMethod(proto, methodName) {
86
+ const current = proto[methodName];
87
+ if (current && current[STRUCT_WRAPPED] && current[STRUCT_ORIGINAL]) {
88
+ proto[methodName] = current[STRUCT_ORIGINAL];
89
+ }
90
+ }
91
+ // Exact client class names (prototype chain covers subclasses) and official
92
+ // Azure endpoint host shapes. Parity: python openai.py.
93
+ const CLASS_NAME_RULES = [
94
+ [new Set(["AzureOpenAI", "BaseAzureOpenAI"]), "azure.ai.openai"],
95
+ ];
96
+ function isAzureHost(host) {
97
+ // Precise SERVICE suffixes only, incl. sovereign clouds (Azure Government
98
+ // .us, Azure China .cn) — generic .azure.{com,us,cn} proxies must not
99
+ // match. Parity: python openai.py's nine suffixes.
100
+ return (host.endsWith(".openai.azure.com") ||
101
+ host.endsWith(".openai.azure.us") ||
102
+ host.endsWith(".openai.azure.cn") ||
103
+ host.endsWith(".cognitiveservices.azure.com") ||
104
+ host.endsWith(".cognitiveservices.azure.us") ||
105
+ host.endsWith(".cognitiveservices.azure.cn") ||
106
+ host.endsWith(".services.ai.azure.com") ||
107
+ host.endsWith(".services.ai.azure.us") ||
108
+ host.endsWith(".services.ai.azure.cn"));
109
+ }
110
+ const HOST_RULES = [
111
+ [isAzureHost, "azure.ai.openai"],
112
+ ];
113
+ function detectProvider(resource) {
114
+ return (0, genai_content_js_1.detectProviderFromResource)(resource, CLASS_NAME_RULES, HOST_RULES, "openai");
115
+ }
116
+ /** @internal */
117
+ exports._detectProviderForTest = detectProvider;
118
+ function wrapCreate(original) {
119
+ return function wrappedCreate(params, opts) {
120
+ const patchCtx = activePatchCtx.value;
121
+ if (!patchCtx) {
122
+ return original.call(this, params, opts);
123
+ }
124
+ // CLASS RULE: detection => propagation, immediately and on EVERY path —
125
+ // before suppression/streaming returns AND before any chat-span telemetry
126
+ // that could fail (a broken tracer or throwing chat-span attribute write
127
+ // must not leave a healthy agent span unattributed). Both calls are
128
+ // internally guarded; write-once makes repeats harmless.
129
+ const provider = detectProvider(this);
130
+ (0, context_js_1.propagateProviderToParent)(provider);
131
+ if ((0, context_js_1.isGenAiSuppressed)()) {
132
+ // A framework layer owns this chat span — run the call, emit no span.
133
+ return original.call(this, params, opts);
134
+ }
135
+ // Preflight reads touch CALLER-owned `params` — a Proxy / lazy request
136
+ // object can have throwing getters, and a throw here (before
137
+ // instrumentCall's guards) would prevent the OpenAI call entirely.
138
+ // Degrade: exactly one plain, uninstrumented original.call.
139
+ let streaming = false;
140
+ let model = "unknown";
141
+ try {
142
+ streaming = !!(params && params.stream === true);
143
+ model = params?.model ?? "unknown";
144
+ }
145
+ catch {
146
+ return original.call(this, params, opts);
147
+ }
148
+ // Streaming is out of scope: pass through untouched, emit no span. The SDK
149
+ // must never consume/buffer a caller's stream. `responses.stream()` routes
150
+ // here internally with stream:true too, so this covers it.
151
+ if (streaming) {
152
+ // Stream passthrough: no chat span; agent already attributed above.
153
+ return original.call(this, params, opts);
154
+ }
155
+ const { tracer, sdk, logger } = patchCtx;
156
+ // All host-boundary safety lives in the shared instrumentCall harness: the
157
+ // host call runs exactly once, nothing host-controllable wraps it, and every
158
+ // telemetry side-effect degrades via safe(). This provider only supplies the
159
+ // telemetry callbacks.
160
+ return (0, instrument_js_1.instrumentCall)(() => original.call(this, params, opts), {
161
+ tracer,
162
+ spanName: `chat ${model}`,
163
+ spanKind: api_1.SpanKind.CLIENT,
164
+ parentContext: childParentContext,
165
+ sitePrefix: "openai.create",
166
+ internalLogger: sdk.getInternalLogger(),
167
+ onStart: (span) => setChatRequestAttrs(span, params, sdk, logger, provider),
168
+ onSuccess: (span, result) => setChatResponseAttrs(span, sdk, result, logger, provider),
169
+ onError: (span, err) => recordErrorOnSpan(span, err),
170
+ });
171
+ };
172
+ }
173
+ function setChatRequestAttrs(span, params, sdk, logger, provider = "openai") {
174
+ span.setAttribute(semconv_js_1.GEN_AI.OPERATION_NAME, "chat");
175
+ span.setAttribute(semconv_js_1.GEN_AI.PROVIDER_NAME, provider);
176
+ const model = params?.model ?? "unknown";
177
+ span.setAttribute(semconv_js_1.GEN_AI.REQUEST_MODEL, model);
178
+ const sessionId = (0, context_js_1.getSessionId)();
179
+ if (sessionId)
180
+ span.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
181
+ // Responses names its output cap `max_output_tokens`; semconv is
182
+ // `gen_ai.request.max_tokens` (same key the Anthropic emitter uses). Truthy
183
+ // check mirrors python (`if kwargs.get("max_output_tokens")`); temp/top_p use
184
+ // `!= null` so 0 is recorded (mirrors python `is not None`).
185
+ if (params?.max_output_tokens) {
186
+ span.setAttribute(semconv_js_1.GEN_AI.REQUEST_MAX_TOKENS, params.max_output_tokens);
187
+ }
188
+ if (params?.temperature != null) {
189
+ span.setAttribute(semconv_js_1.GEN_AI.REQUEST_TEMPERATURE, params.temperature);
190
+ }
191
+ if (params?.top_p != null) {
192
+ span.setAttribute(semconv_js_1.GEN_AI.REQUEST_TOP_P, params.top_p);
193
+ }
194
+ const input = params?.input;
195
+ const instructions = params?.instructions;
196
+ if (typeof input === "string") {
197
+ span.setAttribute(semconv_js_1.STRUCT.INPUT_MESSAGE_COUNT, 1);
198
+ }
199
+ else if (Array.isArray(input)) {
200
+ span.setAttribute(semconv_js_1.STRUCT.INPUT_MESSAGE_COUNT, input.length);
201
+ }
202
+ // Parent-prompt preview is CONTENT — never emit it in ContentCaptureMode.None.
203
+ // Deliberately kept in EventOnly (the default): the agent-span prompt preview
204
+ // is Struct's shipped behavior in both SDKs and what the waterfall UI reads.
205
+ if (sdk.captureContent) {
206
+ (0, genai_content_js_1.propagateUserPromptToParent)((0, openai_content_js_1.lastUserParts)(input));
207
+ }
208
+ if (sdk.emitEvents && logger) {
209
+ (0, events_js_1.emitOpenAIInputMessageEvents)(logger, input, instructions, span, provider);
210
+ }
211
+ if (sdk.emitSpanContent) {
212
+ if (input !== undefined && input !== null) {
213
+ span.setAttribute(semconv_js_1.GEN_AI.INPUT_MESSAGES, (0, openai_content_js_1.toInputMessages)(input));
214
+ }
215
+ if (instructions) {
216
+ span.setAttribute(semconv_js_1.GEN_AI.SYSTEM_INSTRUCTIONS, (0, openai_content_js_1.toSystemInstructions)(instructions));
217
+ }
218
+ if (params?.tools && Array.isArray(params.tools)) {
219
+ span.setAttribute(semconv_js_1.GEN_AI.TOOL_DEFINITIONS, (0, openai_content_js_1.safeJsonForTool)(params.tools));
220
+ }
221
+ }
222
+ }
223
+ function setChatResponseAttrs(span, sdk, response, logger, provider = "openai") {
224
+ const usage = response?.usage;
225
+ if (usage) {
226
+ const inputTokens = usage.input_tokens ?? 0;
227
+ const outputTokens = usage.output_tokens ?? 0;
228
+ const details = usage.input_tokens_details;
229
+ const cacheRead = details?.cached_tokens ?? 0;
230
+ // OpenAI input_tokens ALREADY includes cached tokens — report as-is (NO
231
+ // Anthropic-style add-back; that would double-count).
232
+ span.setAttribute(semconv_js_1.GEN_AI.USAGE_INPUT_TOKENS, inputTokens);
233
+ span.setAttribute(semconv_js_1.GEN_AI.USAGE_OUTPUT_TOKENS, outputTokens);
234
+ const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0;
235
+ if (reasoningTokens) {
236
+ // Subset of output_tokens (chain-of-thought) — observability only, never
237
+ // added to cost. Emitted only when > 0.
238
+ span.setAttribute(semconv_js_1.GEN_AI.USAGE_REASONING_OUTPUT_TOKENS, reasoningTokens);
239
+ }
240
+ if (cacheRead) {
241
+ span.setAttribute(semconv_js_1.GEN_AI.USAGE_CACHE_READ_INPUT_TOKENS, cacheRead);
242
+ }
243
+ const cacheWrite = details?.cache_write_tokens ?? 0;
244
+ if (cacheWrite) {
245
+ // gpt-5.6 charges cache writes (1.25x input). Nested-key name matches the
246
+ // Anthropic emitter / Struct ingest views. Emitted only when > 0.
247
+ span.setAttribute(semconv_js_1.GEN_AI.USAGE_CACHE_CREATION_INPUT_TOKENS, cacheWrite);
248
+ }
249
+ }
250
+ if (response?.model)
251
+ span.setAttribute(semconv_js_1.GEN_AI.RESPONSE_MODEL, response.model);
252
+ if (response?.id)
253
+ span.setAttribute(semconv_js_1.GEN_AI.RESPONSE_ID, response.id);
254
+ // A background (`queued`/`in_progress`) response is NOT terminal — the
255
+ // generation hasn't finished, so there is no assistant message yet. Emitting
256
+ // a terminal finish reason / choice event then would report a still-running
257
+ // request as completed. Skip terminal telemetry until a terminal response is
258
+ // observed; usage/model/id above are still recorded.
259
+ const terminal = (0, openai_content_js_1.isTerminalResponse)(response);
260
+ const finishReason = (0, openai_content_js_1.deriveFinishReason)(response);
261
+ if (finishReason && terminal) {
262
+ span.setAttribute(semconv_js_1.GEN_AI.RESPONSE_FINISH_REASONS, [finishReason]);
263
+ }
264
+ const output = response?.output;
265
+ // Structural (not content) — always runs, like the Anthropic emitter.
266
+ recordPendingToolCalls(output);
267
+ if (sdk.emitEvents && logger && terminal) {
268
+ // The choice emitter maps the finish reason to the spec name internally
269
+ // via mapChoiceFinishReason — pass the RAW derived reason.
270
+ (0, events_js_1.emitOpenAIChoiceEvent)(logger, output, finishReason, span, provider);
271
+ }
272
+ if (sdk.emitSpanContent && terminal) {
273
+ span.setAttribute(semconv_js_1.GEN_AI.OUTPUT_MESSAGES, (0, openai_content_js_1.toOutputMessages)(output, finishReason));
274
+ }
275
+ }
276
+ function recordPendingToolCalls(output) {
277
+ const pairs = (0, openai_content_js_1.iterFunctionCalls)(output);
278
+ if (pairs.length === 0)
279
+ return;
280
+ (0, context_js_1.ensurePendingToolCallsSlot)();
281
+ (0, context_js_1.pushPendingToolCalls)(pairs);
282
+ }
283
+ function recordErrorOnSpan(span, err) {
284
+ const errorType = err instanceof Error ? err.constructor.name : typeof err;
285
+ const message = err instanceof Error ? err.message : String(err);
286
+ span.setAttribute(semconv_js_1.ERROR_TYPE, errorType);
287
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR, message });
288
+ if (err instanceof Error) {
289
+ span.recordException(err);
290
+ }
291
+ }
292
+ /** @internal */
293
+ function _wrapCreateForTest(original) {
294
+ return wrapCreate(original);
295
+ }
296
+ /** @internal */
297
+ exports._setChatRequestAttrsForTest = setChatRequestAttrs;
298
+ function _setActivePatchCtxForTest(ctx) {
299
+ activePatchCtx.value = ctx;
300
+ }
301
+ /** @internal — version-compat tests assert the patch surface across openai releases. */
302
+ function _collectResponsesClassesForTest(mod) {
303
+ return collectResponsesClasses(mod);
304
+ }
305
+ //# sourceMappingURL=openai.js.map
@@ -21,6 +21,7 @@ export declare const GEN_AI: {
21
21
  readonly USAGE_OUTPUT_TOKENS: "gen_ai.usage.output_tokens";
22
22
  readonly USAGE_CACHE_READ_INPUT_TOKENS: "gen_ai.usage.cache_read.input_tokens";
23
23
  readonly USAGE_CACHE_CREATION_INPUT_TOKENS: "gen_ai.usage.cache_creation.input_tokens";
24
+ readonly USAGE_REASONING_OUTPUT_TOKENS: "gen_ai.usage.reasoning.output_tokens";
24
25
  readonly INPUT_MESSAGES: "gen_ai.input.messages";
25
26
  readonly OUTPUT_MESSAGES: "gen_ai.output.messages";
26
27
  readonly SYSTEM_INSTRUCTIONS: "gen_ai.system_instructions";
@@ -24,6 +24,7 @@ exports.GEN_AI = {
24
24
  USAGE_OUTPUT_TOKENS: "gen_ai.usage.output_tokens",
25
25
  USAGE_CACHE_READ_INPUT_TOKENS: "gen_ai.usage.cache_read.input_tokens",
26
26
  USAGE_CACHE_CREATION_INPUT_TOKENS: "gen_ai.usage.cache_creation.input_tokens",
27
+ USAGE_REASONING_OUTPUT_TOKENS: "gen_ai.usage.reasoning.output_tokens",
27
28
  INPUT_MESSAGES: "gen_ai.input.messages",
28
29
  OUTPUT_MESSAGES: "gen_ai.output.messages",
29
30
  SYSTEM_INSTRUCTIONS: "gen_ai.system_instructions",
@@ -6,5 +6,34 @@ type Part = Record<string, unknown>;
6
6
  export declare function truncateParts(parts: Part[]): Part[];
7
7
  export declare function truncateAndSerialize(obj: unknown, maxSize?: number): string;
8
8
  export declare function safeJsonStringify(obj: unknown): string;
9
+ /**
10
+ * Bound the large string/schema fields of a SINGLE tool definition so one
11
+ * oversized tool can't push the whole `gen_ai.tool.definitions` payload past
12
+ * the content cap and trigger `truncateAndSerialize`'s `[]` fallback (which
13
+ * would erase ALL tool telemetry). Tool definitions have no `parts`/`content`
14
+ * fields, so the array-level truncation doesn't reach them — this does.
15
+ *
16
+ * Returns the tool UNCHANGED (same reference) when nothing is oversized, so
17
+ * normal-sized tools serialize byte-identically (no behavior change). Covers
18
+ * both providers' schema key (`parameters` for OpenAI, `input_schema` for
19
+ * Anthropic) plus `description`.
20
+ */
21
+ export declare function truncateToolDefinition(tool: unknown): unknown;
22
+ /**
23
+ * Serialize a provider's tool-definitions to bounded, ALWAYS-valid JSON for the
24
+ * `gen_ai.tool.definitions` attribute. Two levels of bounding:
25
+ *
26
+ * 1. per-tool: each tool's oversized description/schema is capped
27
+ * (`truncateToolDefinition`);
28
+ * 2. per-array: many tools can each fit under `MAX_FIELD_SIZE` yet together
29
+ * exceed `MAX_CONTENT_SIZE`. Rather than let `truncateAndSerialize` byte-cut
30
+ * the array (which can slice inside a nested schema and append `]` →
31
+ * malformed JSON), keep as many WHOLE tool entries as fit and append a
32
+ * truncation-marker entry. Output is guaranteed parseable.
33
+ *
34
+ * Non-arrays fall back to `truncateAndSerialize`. Never throws (the whole thing
35
+ * degrades to a best-effort string).
36
+ */
37
+ export declare function serializeToolDefinitions(obj: unknown): string;
9
38
  export {};
10
39
  //# sourceMappingURL=truncation.d.ts.map
@@ -5,6 +5,8 @@ exports.truncateField = truncateField;
5
5
  exports.truncateParts = truncateParts;
6
6
  exports.truncateAndSerialize = truncateAndSerialize;
7
7
  exports.safeJsonStringify = safeJsonStringify;
8
+ exports.truncateToolDefinition = truncateToolDefinition;
9
+ exports.serializeToolDefinitions = serializeToolDefinitions;
8
10
  exports.MAX_CONTENT_SIZE = 128 * 1024;
9
11
  exports.MAX_FIELD_SIZE = 16384;
10
12
  exports.TRUNCATION_MARKER = "… [truncated]";
@@ -50,9 +52,10 @@ function truncateParts(parts) {
50
52
  return result;
51
53
  }
52
54
  function truncateAndSerialize(obj, maxSize = exports.MAX_CONTENT_SIZE) {
55
+ let items;
53
56
  let result;
54
57
  if (Array.isArray(obj)) {
55
- const truncated = obj.map((item) => {
58
+ items = obj.map((item) => {
56
59
  if (!item || typeof item !== "object")
57
60
  return item;
58
61
  const copy = { ...item };
@@ -64,31 +67,202 @@ function truncateAndSerialize(obj, maxSize = exports.MAX_CONTENT_SIZE) {
64
67
  }
65
68
  return copy;
66
69
  });
67
- result = safeJsonStringify(truncated);
70
+ result = safeJsonStringify(items);
68
71
  }
69
72
  else {
70
73
  result = safeJsonStringify(obj);
71
74
  }
72
75
  if (result.length > maxSize) {
73
- const cut = result.slice(0, maxSize - 50);
74
- const lastBrace = cut.lastIndexOf("}");
75
- if (lastBrace > 0) {
76
- result = cut.slice(0, lastBrace + 1) + "]";
77
- }
78
- else {
79
- result = "[]";
76
+ // NEVER byte-cut serialized JSON — a cut can land inside a nested object
77
+ // (e.g. a message's parts array) and appending "]" then yields malformed
78
+ // output. Instead: (1) shrink any single entry that alone exceeds the
79
+ // budget by dropping whole PARTS (so one huge multipart message keeps a
80
+ // prefix of its parts rather than vanishing); (2) keep whole top-level
81
+ // entries; (3) append a MESSAGE-SHAPED marker entry. Consumers of
82
+ // gen_ai.{input,output}.messages parse these arrays as {role, parts}
83
+ // messages — a bare marker object without `parts` crashes them, so the
84
+ // marker must itself be a valid message.
85
+ if (items) {
86
+ // Shape-aware marker: gen_ai.{input,output}.messages arrays hold
87
+ // {role, parts} MESSAGES, but gen_ai.system_instructions holds bare
88
+ // {type, content} PARTS — a message-shaped marker among parts corrupts
89
+ // that attribute's shape for consumers. Mirror whichever shape the
90
+ // array actually carries.
91
+ const isMessageArray = items.some((i) => !!i && typeof i === "object" && "role" in i);
92
+ const marker = isMessageArray
93
+ ? (dropped) => ({
94
+ role: "system",
95
+ parts: [
96
+ {
97
+ type: "text",
98
+ content: `[struct.truncated: ${dropped} message(s) dropped]`,
99
+ },
100
+ ],
101
+ "struct.truncated": true,
102
+ })
103
+ : (dropped) => ({
104
+ type: "text",
105
+ content: `[struct.truncated: ${dropped} item(s) dropped]`,
106
+ "struct.truncated": true,
107
+ });
108
+ const entryBudget = maxSize - MARKER_HEADROOM - 2;
109
+ const shrunk = items.map((i) => shrinkOversizedPartsEntry(i, entryBudget));
110
+ return capWholeEntries(shrunk, maxSize, marker);
80
111
  }
112
+ // Oversized non-array payload (rare): drop rather than emit malformed JSON.
113
+ return "[]";
81
114
  }
82
115
  return result;
83
116
  }
117
+ const MARKER_HEADROOM = 256;
118
+ /**
119
+ * If a single {role, parts} entry serializes past `maxSize`, keep a prefix of
120
+ * WHOLE parts that fits and append a text marker part — the entry survives with
121
+ * partial content instead of being dropped wholesale. Entries without a parts
122
+ * array are returned unchanged (capWholeEntries will drop them if oversized).
123
+ */
124
+ function shrinkOversizedPartsEntry(item, maxSize) {
125
+ if (!item || typeof item !== "object")
126
+ return item;
127
+ const serialized = safeJsonStringify(item);
128
+ if (serialized.length <= maxSize)
129
+ return item;
130
+ const rec = item;
131
+ if (!Array.isArray(rec.parts))
132
+ return item;
133
+ const envelope = serialized.length - safeJsonStringify(rec.parts).length;
134
+ const kept = [];
135
+ let size = envelope + 2;
136
+ for (const p of rec.parts) {
137
+ const ps = safeJsonStringify(p);
138
+ if (size + ps.length + 1 + MARKER_HEADROOM > maxSize)
139
+ break;
140
+ kept.push(p);
141
+ size += ps.length + 1;
142
+ }
143
+ kept.push({
144
+ type: "text",
145
+ content: `[struct.truncated: ${rec.parts.length - kept.length} part(s) dropped]`,
146
+ });
147
+ return { ...rec, parts: kept, "struct.truncated": true };
148
+ }
149
+ /**
150
+ * Serialize `items` keeping as many WHOLE top-level entries as fit in
151
+ * `maxSize`, appending `marker(droppedCount)` as a final entry when any were
152
+ * dropped. Output is always parseable — the guarantee byte-cutting can't give.
153
+ */
154
+ function capWholeEntries(items, maxSize, marker) {
155
+ // Reserve the ACTUAL worst-case marker size (dropped = items.length has the
156
+ // most digits), not a fixed guess — a fixed reserve can't honor small
157
+ // maxSize values.
158
+ const reserve = safeJsonStringify(marker(items.length)).length + 1;
159
+ const kept = [];
160
+ let size = 2; // "[]"
161
+ for (const item of items) {
162
+ const entry = safeJsonStringify(item);
163
+ if (size + entry.length + 1 + reserve > maxSize)
164
+ break;
165
+ kept.push(item);
166
+ size += entry.length + 1;
167
+ }
168
+ const dropped = items.length - kept.length;
169
+ if (dropped > 0) {
170
+ const markerEntry = marker(dropped);
171
+ if (kept.length === 0 &&
172
+ safeJsonStringify([markerEntry]).length > maxSize) {
173
+ return "[]"; // even the marker alone exceeds the caller's budget
174
+ }
175
+ kept.push(markerEntry);
176
+ }
177
+ return safeJsonStringify(kept);
178
+ }
84
179
  function safeJsonStringify(obj) {
85
180
  try {
86
- return JSON.stringify(obj, defaultReplacer);
181
+ // JSON.stringify returns the VALUE undefined (not a string) for
182
+ // undefined/functions/symbols — normalize to valid JSON so callers can
183
+ // safely read `.length` / embed the result.
184
+ return JSON.stringify(obj, defaultReplacer) ?? "null";
87
185
  }
88
186
  catch {
89
187
  return JSON.stringify(String(obj));
90
188
  }
91
189
  }
190
+ /**
191
+ * Bound the large string/schema fields of a SINGLE tool definition so one
192
+ * oversized tool can't push the whole `gen_ai.tool.definitions` payload past
193
+ * the content cap and trigger `truncateAndSerialize`'s `[]` fallback (which
194
+ * would erase ALL tool telemetry). Tool definitions have no `parts`/`content`
195
+ * fields, so the array-level truncation doesn't reach them — this does.
196
+ *
197
+ * Returns the tool UNCHANGED (same reference) when nothing is oversized, so
198
+ * normal-sized tools serialize byte-identically (no behavior change). Covers
199
+ * both providers' schema key (`parameters` for OpenAI, `input_schema` for
200
+ * Anthropic) plus `description`.
201
+ */
202
+ function truncateToolDefinition(tool) {
203
+ if (!tool || typeof tool !== "object")
204
+ return tool;
205
+ const src = tool;
206
+ let copy;
207
+ const mutable = () => (copy ??= { ...src });
208
+ if (typeof src.description === "string" && src.description.length > exports.MAX_FIELD_SIZE) {
209
+ mutable().description = src.description.slice(0, exports.MAX_FIELD_SIZE) + exports.TRUNCATION_MARKER;
210
+ }
211
+ for (const key of ["parameters", "input_schema"]) {
212
+ const value = src[key];
213
+ if (value && typeof value === "object") {
214
+ const serialized = safeJsonStringify(value);
215
+ if (serialized.length > exports.MAX_FIELD_SIZE) {
216
+ // Keep the schema field a valid OBJECT. Slicing the serialized JSON to a
217
+ // string would parse at the outer payload level but leave the schema
218
+ // non-traversable and violating both providers' tool contract. A
219
+ // sentinel object signals truncation + the original size instead.
220
+ mutable()[key] = {
221
+ "struct.truncated": true,
222
+ original_bytes: serialized.length,
223
+ };
224
+ }
225
+ }
226
+ }
227
+ return copy ?? tool;
228
+ }
229
+ /**
230
+ * Serialize a provider's tool-definitions to bounded, ALWAYS-valid JSON for the
231
+ * `gen_ai.tool.definitions` attribute. Two levels of bounding:
232
+ *
233
+ * 1. per-tool: each tool's oversized description/schema is capped
234
+ * (`truncateToolDefinition`);
235
+ * 2. per-array: many tools can each fit under `MAX_FIELD_SIZE` yet together
236
+ * exceed `MAX_CONTENT_SIZE`. Rather than let `truncateAndSerialize` byte-cut
237
+ * the array (which can slice inside a nested schema and append `]` →
238
+ * malformed JSON), keep as many WHOLE tool entries as fit and append a
239
+ * truncation-marker entry. Output is guaranteed parseable.
240
+ *
241
+ * Non-arrays fall back to `truncateAndSerialize`. Never throws (the whole thing
242
+ * degrades to a best-effort string).
243
+ */
244
+ function serializeToolDefinitions(obj) {
245
+ try {
246
+ if (!Array.isArray(obj))
247
+ return truncateAndSerialize(obj);
248
+ const bounded = obj.map(truncateToolDefinition);
249
+ const serialized = safeJsonStringify(bounded);
250
+ if (serialized.length <= exports.MAX_CONTENT_SIZE)
251
+ return serialized;
252
+ // Overflow: keep whole tool entries + a marker (shared core with
253
+ // truncateAndSerialize — never a mid-object byte slice).
254
+ return capWholeEntries(bounded, exports.MAX_CONTENT_SIZE, (dropped) => ({
255
+ "struct.truncated": true,
256
+ dropped_tools: dropped,
257
+ }));
258
+ }
259
+ catch {
260
+ // Terminal, NON-host-controlled fallback. Re-serializing `obj` here could
261
+ // return the original oversized payload (size guarantee violated) or throw
262
+ // again via hostile toString/map overrides — degrade to an empty array.
263
+ return "[]";
264
+ }
265
+ }
92
266
  function defaultReplacer(_key, value) {
93
267
  if (typeof value === "bigint")
94
268
  return value.toString();
@@ -1,2 +1,2 @@
1
- export declare const SDK_VERSION = "0.3.17";
1
+ export declare const SDK_VERSION = "0.4.2";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SDK_VERSION = void 0;
4
4
  // Keep in sync with package.json; test/version.test.ts enforces it.
5
- exports.SDK_VERSION = "0.3.17";
5
+ exports.SDK_VERSION = "0.4.2";
6
6
  //# sourceMappingURL=version.js.map
@@ -53,4 +53,23 @@ export declare function snapshotStore(): StructContext | undefined;
53
53
  export declare function runWithStore<T>(store: StructContext | undefined, fn: () => T): T;
54
54
  /** Test-only: run `fn` inside a completely fresh context (no parent store). */
55
55
  export declare function runInFreshContext<T>(fn: () => T): T;
56
+ /**
57
+ * Write-once `gen_ai.provider.name` on an invoke_agent span.
58
+ *
59
+ * CONTRACT: `agentSpan` is always an SDK-OWNED span — created by our own
60
+ * tracer in `struct.agent()` or the LangChain handler and delivered via the
61
+ * ALS store / run map, which nothing else writes. Never a host object. So
62
+ * the industry-standard owned-object pattern applies (state lives ON the
63
+ * object — Sentry/dd-trace private span fields, OTel JS symbol markers): a
64
+ * private Symbol sentinel set after a successful write. No registries or
65
+ * lifecycle bookkeeping — those are for FOREIGN objects.
66
+ *
67
+ * Semantics: "a real child provider" — racing children with different
68
+ * providers may pick either; the sentinel is set only after a successful
69
+ * write so a transient failure can be retried. Parity: python
70
+ * `stamp_provider_once`.
71
+ */
72
+ export declare function stampProviderOnce(agentSpan: Span | undefined, provider: string | undefined): void;
73
+ /** Stamp the ambient agent span with the child call's provider (write-once). */
74
+ export declare function propagateProviderToParent(provider: string | undefined): void;
56
75
  //# sourceMappingURL=context.d.ts.map