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