@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,360 @@
1
+ import { EVENT_NAMES } from "../semconv.js";
2
+ import { serializeToolDefinitions, truncateAndSerialize } from "../truncation.js";
3
+ /** Read `key` from a Responses item that may be a plain object or SDK object. */
4
+ function get(item, key, dflt) {
5
+ if (item == null || typeof item !== "object")
6
+ return dflt;
7
+ const v = item[key];
8
+ return v === undefined ? dflt : v;
9
+ }
10
+ /** Map a Responses `input_image.image_url` to a spec image part (never embeds full base64). */
11
+ function parseImageUrl(imageUrl) {
12
+ if (typeof imageUrl === "string" && imageUrl.startsWith("data:")) {
13
+ try {
14
+ const idx = imageUrl.indexOf(",");
15
+ const header = idx >= 0 ? imageUrl.slice(0, idx) : imageUrl; // "data:<mime>;base64"
16
+ const data = idx >= 0 ? imageUrl.slice(idx + 1) : "";
17
+ const mime = header.slice("data:".length).split(";")[0] || undefined;
18
+ return {
19
+ type: "blob",
20
+ modality: "image",
21
+ content: data.slice(0, 256) + "...",
22
+ mime_type: mime,
23
+ };
24
+ }
25
+ catch {
26
+ /* malformed data URL falls through to uri */
27
+ }
28
+ }
29
+ return { type: "uri", modality: "image", uri: imageUrl || "" };
30
+ }
31
+ /** Map ONE entry inside a Responses `message.content` list to a spec part. */
32
+ function contentEntryToPart(entry) {
33
+ if (typeof entry === "string")
34
+ return { type: "text", content: entry };
35
+ const entryType = get(entry, "type");
36
+ if (entryType === "input_text" || entryType === "output_text") {
37
+ return { type: "text", content: get(entry, "text", "") || "" };
38
+ }
39
+ if (entryType === "refusal") {
40
+ return { type: "text", content: get(entry, "refusal", "") || "" };
41
+ }
42
+ if (entryType === "input_image" || entryType === "output_image") {
43
+ // A Responses image can reference an uploaded file by `file_id` instead of
44
+ // an `image_url`. Preserve that identity as a file part (never embed file
45
+ // contents — we don't have them) rather than dropping it to an empty uri.
46
+ const fileId = get(entry, "file_id");
47
+ if (typeof fileId === "string" && fileId) {
48
+ return { type: "file", modality: "image", file_id: fileId };
49
+ }
50
+ const url = get(entry, "image_url", "") || "";
51
+ if (entryType === "input_image")
52
+ return parseImageUrl(url);
53
+ return url ? parseImageUrl(url) : { type: "output_image" };
54
+ }
55
+ if (entryType === "input_file") {
56
+ // Preserve safe identity fields; NEVER embed the base64 `file_data`.
57
+ const part = { type: "file" };
58
+ const fileId = get(entry, "file_id");
59
+ const filename = get(entry, "filename");
60
+ const fileUrl = get(entry, "file_url");
61
+ if (typeof fileId === "string" && fileId)
62
+ part.file_id = fileId;
63
+ if (typeof filename === "string" && filename)
64
+ part.filename = filename;
65
+ if (typeof fileUrl === "string" && fileUrl)
66
+ part.uri = fileUrl;
67
+ return part;
68
+ }
69
+ if (entryType === "input_audio") {
70
+ // Preserve only the audio format; NEVER embed the base64 `data`.
71
+ const part = { type: "audio", modality: "audio" };
72
+ const audio = get(entry, "input_audio");
73
+ const format = get(audio, "format") ?? get(entry, "format");
74
+ if (typeof format === "string" && format)
75
+ part.format = format;
76
+ return part;
77
+ }
78
+ return { type: entryType || "unknown" };
79
+ }
80
+ /** Map a Responses `message.content` (string or list) to spec parts. */
81
+ export function messageContentToParts(content) {
82
+ if (content == null)
83
+ return [];
84
+ if (typeof content === "string")
85
+ return [{ type: "text", content }];
86
+ if (!Array.isArray(content))
87
+ return [];
88
+ return content.map(contentEntryToPart);
89
+ }
90
+ /** Map a top-level `function_call` item to a single `tool_call` part. */
91
+ function functionCallParts(item) {
92
+ const part = { type: "tool_call", name: get(item, "name", "") || "" };
93
+ const callId = get(item, "call_id");
94
+ if (callId)
95
+ part.id = callId;
96
+ const rawArgs = get(item, "arguments");
97
+ if (rawArgs !== undefined && rawArgs !== null) {
98
+ if (typeof rawArgs === "string") {
99
+ // JSON string on the wire; parse to a dict when we can, else keep raw.
100
+ try {
101
+ part.arguments = rawArgs ? JSON.parse(rawArgs) : {};
102
+ }
103
+ catch {
104
+ part.arguments = rawArgs;
105
+ }
106
+ }
107
+ else {
108
+ part.arguments = rawArgs;
109
+ }
110
+ }
111
+ return [part];
112
+ }
113
+ /**
114
+ * Map a function_call_output / custom_tool_call_output item to a single
115
+ * tool_call_response part. `output` may be a string OR an array of
116
+ * input-content items — arrays route through the safe content mapper so inline
117
+ * base64 (`file_data`, audio `data`) is never copied verbatim.
118
+ */
119
+ function toolResponseParts(item) {
120
+ const part = { type: "tool_call_response" };
121
+ const callId = get(item, "call_id");
122
+ if (callId)
123
+ part.id = callId;
124
+ const output = get(item, "output", "");
125
+ part.response = Array.isArray(output) ? messageContentToParts(output) : output;
126
+ return [part];
127
+ }
128
+ /** Map a `custom_tool_call` item to a tool_call part with its RAW string input. */
129
+ function customToolCallParts(item) {
130
+ const part = { type: "tool_call", name: get(item, "name", "") || "" };
131
+ const callId = get(item, "call_id");
132
+ if (callId)
133
+ part.id = callId;
134
+ const input = get(item, "input");
135
+ if (typeof input === "string")
136
+ part.arguments = input; // freeform — keep raw
137
+ return [part];
138
+ }
139
+ const EVENT_NAME_MAP = {
140
+ user: EVENT_NAMES.USER_MESSAGE,
141
+ assistant: EVENT_NAMES.ASSISTANT_MESSAGE,
142
+ system: EVENT_NAMES.SYSTEM_MESSAGE,
143
+ // OpenAI's `developer` role is system-level instruction; Struct renders only
144
+ // the known user/assistant/system/tool event names, so map it to the system
145
+ // event (the payload keeps `role: "developer"` for fidelity).
146
+ developer: EVENT_NAMES.SYSTEM_MESSAGE,
147
+ };
148
+ /**
149
+ * True for a Responses `input` item that is a chat message. Covers the explicit
150
+ * ``{"type": "message", ...}`` form AND OpenAI's ``EasyInputMessage`` shorthand,
151
+ * where ``type`` is OPTIONAL (``type?: "message"``) — e.g.
152
+ * ``{"role": "user", "content": "hi"}``. A typeless item counts only when it
153
+ * carries a string ``role`` (so we never treat function_call / reasoning / other
154
+ * typeless objects as messages).
155
+ */
156
+ function isMessageItem(item) {
157
+ const itemType = get(item, "type");
158
+ if (itemType === "message")
159
+ return true;
160
+ return ((itemType === undefined || itemType === null) &&
161
+ typeof get(item, "role") === "string");
162
+ }
163
+ /** Map ONE Responses `input` item to `[eventName, role, parts]`, or null. */
164
+ export function inputItemToEvent(item) {
165
+ if (isMessageItem(item)) {
166
+ const role = get(item, "role", "user") || "user";
167
+ const parts = messageContentToParts(get(item, "content"));
168
+ const eventName = EVENT_NAME_MAP[role] ?? `gen_ai.${role}.message`;
169
+ return [eventName, role, parts];
170
+ }
171
+ const itemType = get(item, "type");
172
+ if (itemType === "function_call") {
173
+ return [EVENT_NAMES.ASSISTANT_MESSAGE, "assistant", functionCallParts(item)];
174
+ }
175
+ if (itemType === "custom_tool_call") {
176
+ // OpenAI custom tools: same semantics as function_call but `input` is a
177
+ // FREEFORM string — never JSON.parse it.
178
+ return [EVENT_NAMES.ASSISTANT_MESSAGE, "assistant", customToolCallParts(item)];
179
+ }
180
+ if (itemType === "function_call_output" || itemType === "custom_tool_call_output") {
181
+ return [EVENT_NAMES.TOOL_MESSAGE, "tool", toolResponseParts(item)];
182
+ }
183
+ // Deliberately NO message event for everything else:
184
+ // - reasoning / compaction / compaction_trigger (encrypted_content NEVER read)
185
+ // - builtin/agentic tool machinery — computer_call(+output), web_search_call,
186
+ // file_search_call, image_generation_call, code_interpreter_call,
187
+ // local_shell_call(+output), and the v6-era shell_call(+output),
188
+ // apply_patch_call(+output), tool_search_call/tool_search_output,
189
+ // additional_tools, program(+output), mcp_* —
190
+ // these are not chat messages, and their payloads (shell commands, patches,
191
+ // program code) are exactly the sensitive fields we must not serialize.
192
+ // - unknown future items.
193
+ // The shape-sweep test pins every documented member of this policy.
194
+ return null;
195
+ }
196
+ /** Map ONE `response.output` item to the assistant's choice parts. */
197
+ export function outputItemToChoiceParts(item) {
198
+ const itemType = get(item, "type");
199
+ if (itemType === "message")
200
+ return messageContentToParts(get(item, "content"));
201
+ if (itemType === "function_call")
202
+ return functionCallParts(item);
203
+ if (itemType === "custom_tool_call")
204
+ return customToolCallParts(item);
205
+ // v6 ResponseOutputItem also carries tool RESULTS — capture them (with the
206
+ // same array base64-stripping as the input path). Other agentic *_output
207
+ // items (computer/shell/apply_patch/…) stay privacy-skipped below.
208
+ if (itemType === "function_call_output" || itemType === "custom_tool_call_output") {
209
+ return toolResponseParts(item);
210
+ }
211
+ return [];
212
+ }
213
+ /** Normalize the `input` kwarg to a list of items (bare string → one user message). */
214
+ export function normalizeInput(input) {
215
+ if (typeof input === "string") {
216
+ return [
217
+ { type: "message", role: "user", content: [{ type: "input_text", text: input }] },
218
+ ];
219
+ }
220
+ if (Array.isArray(input))
221
+ return input;
222
+ return [];
223
+ }
224
+ /** Spec parts of the LAST `user` message in `input` (for parent propagation). */
225
+ export function lastUserParts(input) {
226
+ const items = normalizeInput(input);
227
+ for (let i = items.length - 1; i >= 0; i--) {
228
+ const item = items[i];
229
+ if (isMessageItem(item) && get(item, "role") === "user") {
230
+ return messageContentToParts(get(item, "content"));
231
+ }
232
+ }
233
+ return undefined;
234
+ }
235
+ /** Every function_call `(name, call_id)` from a response.output (for tool linkage). */
236
+ export function iterFunctionCalls(output) {
237
+ const pairs = [];
238
+ if (!Array.isArray(output))
239
+ return pairs;
240
+ for (const item of output) {
241
+ const itemType = get(item, "type");
242
+ // custom_tool_call is a tool invocation too — its call_id is what a
243
+ // custom_tool_call_output echoes, so @struct.tool() linkage needs it.
244
+ if (itemType !== "function_call" && itemType !== "custom_tool_call")
245
+ continue;
246
+ const name = get(item, "name", "") || "";
247
+ const callId = get(item, "call_id", "") || "";
248
+ if (name && callId)
249
+ pairs.push([name, callId]);
250
+ }
251
+ return pairs;
252
+ }
253
+ /** Derive a raw finish-reason string from a Responses response. */
254
+ export function deriveFinishReason(response) {
255
+ const incomplete = get(response, "incomplete_details");
256
+ if (incomplete != null) {
257
+ const reason = get(incomplete, "reason", "") || "";
258
+ return reason ? `incomplete:${reason}` : "incomplete";
259
+ }
260
+ return get(response, "status") || undefined;
261
+ }
262
+ const TERMINAL_STATUSES = new Set([
263
+ "completed",
264
+ "incomplete",
265
+ "failed",
266
+ "cancelled",
267
+ ]);
268
+ /**
269
+ * True when a Responses response is in a terminal state (generation finished).
270
+ * A `background: true` request can return non-terminal (`"queued"` /
271
+ * `"in_progress"`) with no assistant message yet — we must NOT emit a terminal
272
+ * `gen_ai.choice` / finish reason for those. A response with no status is
273
+ * treated as terminal so we never silently drop telemetry for the common
274
+ * synchronous call (or minimal mocks).
275
+ */
276
+ export function isTerminalResponse(response) {
277
+ if (get(response, "incomplete_details") != null)
278
+ return true;
279
+ const status = get(response, "status");
280
+ if (status == null)
281
+ return true;
282
+ return TERMINAL_STATUSES.has(status);
283
+ }
284
+ // Only terminal statuses map to a spec choice finish reason; non-terminal
285
+ // (queued/in_progress) responses never reach here — the caller skips the choice
286
+ // event entirely (see isTerminalResponse).
287
+ const FINISH_REASON_MAP = {
288
+ completed: "stop",
289
+ // "incomplete" is handled reason-aware in mapChoiceFinishReason (length vs
290
+ // content_filter), not here.
291
+ failed: "error",
292
+ cancelled: "stop",
293
+ };
294
+ /** Map a raw Responses status/derived reason to a spec choice finish reason. */
295
+ export function mapChoiceFinishReason(raw) {
296
+ if (!raw)
297
+ return "stop";
298
+ const [base, detail] = raw.split(":"); // e.g. "incomplete:max_output_tokens"
299
+ if (base === "incomplete") {
300
+ // Documented incomplete reasons: max_output_tokens → length,
301
+ // content_filter → content_filter (its own spec value). Unknown future
302
+ // reasons fall back to length (the generic "cut short" semantics).
303
+ if (detail === "content_filter")
304
+ return "content_filter";
305
+ return "length";
306
+ }
307
+ return FINISH_REASON_MAP[base] ?? "stop";
308
+ }
309
+ /** Serialize `input` → spec `gen_ai.input.messages` JSON string. */
310
+ export function toInputMessages(input) {
311
+ try {
312
+ const result = [];
313
+ for (const item of normalizeInput(input)) {
314
+ const mapped = inputItemToEvent(item);
315
+ if (!mapped)
316
+ continue;
317
+ const [, role, parts] = mapped;
318
+ result.push({ role, parts });
319
+ }
320
+ return truncateAndSerialize(result);
321
+ }
322
+ catch {
323
+ return "[]";
324
+ }
325
+ }
326
+ /** Serialize Responses `instructions` → spec `system_instructions` JSON string. */
327
+ export function toSystemInstructions(instructions) {
328
+ try {
329
+ if (typeof instructions === "string") {
330
+ return truncateAndSerialize([{ type: "text", content: instructions }]);
331
+ }
332
+ if (instructions) {
333
+ return truncateAndSerialize([{ type: "text", content: String(instructions) }]);
334
+ }
335
+ return "[]";
336
+ }
337
+ catch {
338
+ return "[]";
339
+ }
340
+ }
341
+ /** Serialize `response.output` → spec `gen_ai.output.messages` JSON string. */
342
+ export function toOutputMessages(output, finishReason) {
343
+ try {
344
+ const parts = [];
345
+ for (const item of Array.isArray(output) ? output : []) {
346
+ parts.push(...outputItemToChoiceParts(item));
347
+ }
348
+ const msg = { role: "assistant", parts };
349
+ if (finishReason)
350
+ msg.finish_reason = mapChoiceFinishReason(finishReason);
351
+ return truncateAndSerialize([msg]);
352
+ }
353
+ catch {
354
+ return "[]";
355
+ }
356
+ }
357
+ export function safeJsonForTool(obj) {
358
+ return serializeToolDefinitions(obj);
359
+ }
360
+ //# sourceMappingURL=openai-content.js.map
@@ -0,0 +1,39 @@
1
+ import { type Span, type Tracer } from "@opentelemetry/api";
2
+ import type { Logger } from "@opentelemetry/api-logs";
3
+ import type { StructSDK } from "../core.js";
4
+ interface PatchContext {
5
+ tracer: Tracer;
6
+ sdk: StructSDK;
7
+ logger: Logger | undefined;
8
+ }
9
+ interface CreateParams {
10
+ model?: string;
11
+ max_output_tokens?: number;
12
+ temperature?: number;
13
+ top_p?: number;
14
+ input?: unknown;
15
+ instructions?: unknown;
16
+ tools?: unknown[];
17
+ stream?: boolean;
18
+ }
19
+ export declare function patch(sdk: StructSDK): Promise<void>;
20
+ export declare function unpatch(): Promise<void>;
21
+ type CreateMethod = (this: unknown, params: CreateParams, opts?: unknown) => unknown;
22
+ declare function detectProvider(resource: unknown): string;
23
+ /** @internal */
24
+ export declare const _detectProviderForTest: typeof detectProvider;
25
+ export declare function wrapCreate(original: CreateMethod): CreateMethod;
26
+ declare function setChatRequestAttrs(span: Span, params: CreateParams | undefined, sdk: StructSDK, logger: Logger | undefined, provider?: string): void;
27
+ /** @internal */
28
+ export type _PatchContextForTest = PatchContext;
29
+ /** @internal */
30
+ export declare function _wrapCreateForTest(original: CreateMethod): CreateMethod;
31
+ /** @internal */
32
+ export declare const _setChatRequestAttrsForTest: typeof setChatRequestAttrs;
33
+ export declare function _setActivePatchCtxForTest(ctx: PatchContext | undefined): void;
34
+ /** @internal — version-compat tests assert the patch surface across openai releases. */
35
+ export declare function _collectResponsesClassesForTest(mod: Record<string, unknown>): Array<{
36
+ prototype: Record<string, unknown>;
37
+ }>;
38
+ export {};
39
+ //# sourceMappingURL=openai.d.ts.map