@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.
- package/README.md +101 -16
- package/dist/commonjs/context.d.ts +45 -0
- package/dist/commonjs/context.js +78 -1
- package/dist/commonjs/core.js +184 -29
- package/dist/commonjs/events.d.ts +17 -6
- package/dist/commonjs/events.js +82 -59
- package/dist/commonjs/genai-content.d.ts +52 -0
- package/dist/commonjs/genai-content.js +143 -0
- package/dist/commonjs/instrument.d.ts +47 -0
- package/dist/commonjs/instrument.js +158 -0
- package/dist/commonjs/integrations/anthropic-content.js +18 -6
- package/dist/commonjs/integrations/anthropic.d.ts +8 -1
- package/dist/commonjs/integrations/anthropic.js +515 -104
- package/dist/commonjs/integrations/index.js +8 -0
- package/dist/commonjs/integrations/langchain-callback.d.ts +182 -27
- package/dist/commonjs/integrations/langchain-callback.js +754 -87
- package/dist/commonjs/integrations/langchain-content.js +1 -1
- package/dist/commonjs/integrations/langchain.d.ts +3 -0
- package/dist/commonjs/integrations/langchain.js +353 -7
- package/dist/commonjs/integrations/openai-content.d.ts +34 -0
- package/dist/commonjs/integrations/openai-content.js +375 -0
- package/dist/commonjs/integrations/openai.d.ts +39 -0
- package/dist/commonjs/integrations/openai.js +305 -0
- package/dist/commonjs/semconv.d.ts +12 -0
- package/dist/commonjs/semconv.js +13 -1
- package/dist/commonjs/truncation.d.ts +29 -0
- package/dist/commonjs/truncation.js +184 -10
- package/dist/commonjs/version.d.ts +2 -0
- package/dist/commonjs/version.js +6 -0
- package/dist/esm/context.d.ts +45 -0
- package/dist/esm/context.js +74 -1
- package/dist/esm/core.js +185 -30
- package/dist/esm/events.d.ts +17 -6
- package/dist/esm/events.js +82 -61
- package/dist/esm/genai-content.d.ts +52 -0
- package/dist/esm/genai-content.js +137 -0
- package/dist/esm/instrument.d.ts +47 -0
- package/dist/esm/instrument.js +155 -0
- package/dist/esm/integrations/anthropic-content.js +19 -7
- package/dist/esm/integrations/anthropic.d.ts +8 -1
- package/dist/esm/integrations/anthropic.js +514 -107
- package/dist/esm/integrations/index.js +8 -0
- package/dist/esm/integrations/langchain-callback.d.ts +182 -27
- package/dist/esm/integrations/langchain-callback.js +756 -89
- package/dist/esm/integrations/langchain-content.js +1 -1
- package/dist/esm/integrations/langchain.d.ts +3 -0
- package/dist/esm/integrations/langchain.js +352 -7
- package/dist/esm/integrations/openai-content.d.ts +34 -0
- package/dist/esm/integrations/openai-content.js +360 -0
- package/dist/esm/integrations/openai.d.ts +39 -0
- package/dist/esm/integrations/openai.js +296 -0
- package/dist/esm/semconv.d.ts +12 -0
- package/dist/esm/semconv.js +12 -0
- package/dist/esm/truncation.d.ts +29 -0
- package/dist/esm/truncation.js +182 -10
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +3 -0
- 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
|
package/dist/esm/semconv.d.ts
CHANGED
|
@@ -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: {
|
package/dist/esm/semconv.js
CHANGED
|
@@ -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 = {
|
package/dist/esm/truncation.d.ts
CHANGED
|
@@ -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
|
package/dist/esm/truncation.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
61
|
+
result = safeJsonStringify(items);
|
|
61
62
|
}
|
|
62
63
|
else {
|
|
63
64
|
result = safeJsonStringify(obj);
|
|
64
65
|
}
|
|
65
66
|
if (result.length > maxSize) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@struct-ai/sdk",
|
|
3
|
-
"version": "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",
|