@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
@@ -1,10 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { context as otelContext, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
3
- import { getAgentSpan, getManualAgentSpan, getPendingToolCalls, getSessionId, } from "../context.js";
3
+ import { getAgentSpan, getManualAgentSpan, getPendingToolCalls, getSessionId, stampProviderOnce, } from "../context.js";
4
4
  import { safe } from "../core.js";
5
5
  import { ERROR_TYPE, EVENT_NAMES, EVENT_NAME, GEN_AI, LANGCHAIN, LANGCHAIN_FINISH_REASON_MAP, ROLE_TO_EVENT_NAME, STRUCT, } from "../semconv.js";
6
6
  import { safeJsonStringify, truncateAndSerialize, truncateParts, } from "../truncation.js";
7
- import { detectProvider, langchainMessageToRoleAndParts, langchainToInputMessages, langchainToOutputMessages, lastUserMessageParts, } from "./langchain-content.js";
7
+ import { MODULE_PROVIDER_MAP, detectProvider, langchainMessageToRoleAndParts, langchainToInputMessages, langchainToOutputMessages, lastUserMessageParts, } from "./langchain-content.js";
8
8
  /**
9
9
  * LangChain CallbackHandler — creates OTel spans from LangChain callbacks.
10
10
  *
@@ -327,7 +327,9 @@ export class StructCallbackHandler {
327
327
  const startedSpan = span;
328
328
  safe(() => {
329
329
  startedSpan.setAttribute(GEN_AI.OPERATION_NAME, "invoke_agent");
330
- startedSpan.setAttribute(GEN_AI.PROVIDER_NAME, "langchain");
330
+ // gen_ai.provider.name is stamped later by the first chat run under
331
+ // this agent (write-once) — "langchain" is a framework, not a
332
+ // provider, and the real one isn't known at chain start.
331
333
  startedSpan.setAttribute(GEN_AI.AGENT_NAME, String(agentName));
332
334
  // Do NOT set gen_ai.agent.id from sessionId — that conflates agent
333
335
  // identity (spec: stable agent-definition id) with a per-invocation
@@ -420,6 +422,17 @@ export class StructCallbackHandler {
420
422
  const parentCtx = parentSpan
421
423
  ? trace.setSpan(otelContext.active(), parentSpan)
422
424
  : otelContext.active();
425
+ // CLASS RULE: detection => propagation BEFORE any child-span telemetry —
426
+ // a throwing tracer or failing chat-span write must not leave the healthy
427
+ // ancestor agent provider-less. ("langchain" is the unknown-model
428
+ // fallback sentinel, never propagated.) Parity: python
429
+ // on_chat_model_start stamp_ancestor.
430
+ safe(() => {
431
+ const providerAncestor = this.inheritedAgentSpan(parentRunId);
432
+ if (providerAncestor && provider !== "langchain") {
433
+ stampProviderOnce(providerAncestor, provider);
434
+ }
435
+ }, "langchain.handleChatModelStart.stamp_provider", this.internalLogger);
423
436
  let span;
424
437
  safe(() => {
425
438
  span = this.tracer.startSpan(`chat ${model}`, { kind: SpanKind.CLIENT }, parentCtx);
@@ -571,7 +584,8 @@ export class StructCallbackHandler {
571
584
  const queueOwnership = this.resolveQueueOwnership(parentRunId);
572
585
  safe(() => {
573
586
  startedSpan.setAttribute(GEN_AI.OPERATION_NAME, "execute_tool");
574
- startedSpan.setAttribute(GEN_AI.PROVIDER_NAME, "langchain");
587
+ // No gen_ai.provider.name: the spec's execute_tool span does not
588
+ // define that attribute.
575
589
  startedSpan.setAttribute(GEN_AI.TOOL_NAME, String(toolName));
576
590
  // Tool call id from metadata (LangChain passes it there for ToolCall
577
591
  // inputs) or fallback to the pending queue populated by the LLM's
@@ -655,7 +669,18 @@ export class StructCallbackHandler {
655
669
  span.setAttribute(GEN_AI.TOOL_CALL_RESULT, safeJsonStringify(output).slice(0, 8192));
656
670
  }
657
671
  }, "langchain.handleToolEnd.set_result", this.internalLogger);
658
- safe(() => span.setStatus({ code: SpanStatusCode.OK }), "langchain.handleToolEnd.set_status", this.internalLogger);
672
+ safe(() => {
673
+ if (toolOutputSignalsError(output)) {
674
+ span.setAttribute(ERROR_TYPE, "tool_error");
675
+ span.setStatus({
676
+ code: SpanStatusCode.ERROR,
677
+ message: "tool returned an error result",
678
+ });
679
+ }
680
+ else {
681
+ span.setStatus({ code: SpanStatusCode.OK });
682
+ }
683
+ }, "langchain.handleToolEnd.set_status", this.internalLogger);
659
684
  safe(() => span.end(), "langchain.handleToolEnd.span_end", this.internalLogger);
660
685
  };
661
686
  handleToolError = (err, runId) => {
@@ -688,7 +713,8 @@ export class StructCallbackHandler {
688
713
  const sessionId = this.resolveSessionId(parentRunId, metadata);
689
714
  safe(() => {
690
715
  startedSpan.setAttribute(GEN_AI.OPERATION_NAME, "retrieval");
691
- startedSpan.setAttribute(GEN_AI.PROVIDER_NAME, "langchain");
716
+ // No gen_ai.provider.name: not an inference span, and "langchain"
717
+ // isn't a provider.
692
718
  startedSpan.setAttribute(GEN_AI.DATA_SOURCE_ID, String(name));
693
719
  if (sessionId)
694
720
  startedSpan.setAttribute(GEN_AI.CONVERSATION_ID, sessionId);
@@ -907,6 +933,11 @@ export class StructCallbackHandler {
907
933
  }
908
934
  propagateUserPrompt(parentSpan, messages) {
909
935
  try {
936
+ // Parent-prompt preview is CONTENT — never emit it in ContentCaptureMode
937
+ // .None (same gate as the anthropic/openai provider paths; kept in
938
+ // EventOnly deliberately: shipped behavior the waterfall UI reads).
939
+ if (!this.sdk.captureContent)
940
+ return;
910
941
  const attrs = parentSpan
911
942
  .attributes;
912
943
  if (attrs && attrs[GEN_AI.INPUT_MESSAGES])
@@ -1112,8 +1143,30 @@ function extractClassName(obj) {
1112
1143
  function detectProviderFromSerialized(llm) {
1113
1144
  const cls = extractClassName(llm) ?? "";
1114
1145
  const fake = { constructor: { name: cls }, _llmType: undefined };
1115
- return detectProvider(fake);
1146
+ const byClass = detectProvider(fake);
1147
+ if (byClass !== "langchain")
1148
+ return byClass;
1149
+ // Module-path fallback (parity with python's _detect_provider_from_serialized):
1150
+ // an unknown/wrapped class under a known provider module (e.g.
1151
+ // langchain_openai) still resolves to the real provider.
1152
+ const ids = llm.id;
1153
+ const modulePath = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : "";
1154
+ // SEGMENT-exact matching, scoped to langchain partner packages — substring
1155
+ // classified lookalikes (langchain_notopenai -> openai), and the value gets
1156
+ // write-once stamped onto agent spans, so a false positive is sticky.
1157
+ if (modulePath === "langchain" ||
1158
+ modulePath.startsWith("langchain_") ||
1159
+ modulePath.startsWith("langchain.")) {
1160
+ const segments = modulePath.split(/[._]/).filter(Boolean);
1161
+ for (const [key, provider] of Object.entries(MODULE_PROVIDER_MAP)) {
1162
+ if (segments.includes(key))
1163
+ return provider;
1164
+ }
1165
+ }
1166
+ return "langchain";
1116
1167
  }
1168
+ /** @internal */
1169
+ export const _detectProviderFromSerializedForTest = detectProviderFromSerialized;
1117
1170
  function extractParam(obj, key) {
1118
1171
  if (!obj || typeof obj !== "object")
1119
1172
  return undefined;
@@ -1340,4 +1393,29 @@ function recordError(span, err) {
1340
1393
  if (err instanceof Error)
1341
1394
  span.recordException(err);
1342
1395
  }
1396
+ /**
1397
+ * Whether a LangChain tool OUTPUT signals in-band failure: a
1398
+ * ToolMessage with status "error", or an MCP-style isError/is_error
1399
+ * boolean-true flag on the top-level object. Mirrors python
1400
+ * langchain.on_tool_end semantics — keep in lockstep.
1401
+ */
1402
+ function toolOutputSignalsError(output) {
1403
+ if (typeof output !== "object" || output === null)
1404
+ return false;
1405
+ // Per-key probe isolation: a hostile getter on one key must not mask a
1406
+ // readable sibling. Mirrors python _tool_output_signals_error.
1407
+ return (probe(output, "status") === "error" ||
1408
+ probe(output, "isError") === true ||
1409
+ probe(output, "is_error") === true);
1410
+ }
1411
+ /** Isolated single-property read of untrusted host data (see core.ts
1412
+ * safeProbe — duplicated here because it is module-private there). */
1413
+ function probe(obj, key) {
1414
+ try {
1415
+ return obj[key];
1416
+ }
1417
+ catch {
1418
+ return undefined;
1419
+ }
1420
+ }
1343
1421
  //# sourceMappingURL=langchain-callback.js.map
@@ -2,7 +2,7 @@ import { truncateAndSerialize } from "../truncation.js";
2
2
  import { LANGCHAIN_FINISH_REASON_MAP } from "../semconv.js";
3
3
  export const PROVIDER_MAP = {
4
4
  ChatOpenAI: "openai",
5
- AzureChatOpenAI: "azure.openai",
5
+ AzureChatOpenAI: "azure.ai.openai",
6
6
  ChatAnthropic: "anthropic",
7
7
  ChatGoogleGenerativeAI: "gcp.generative_ai",
8
8
  ChatVertexAI: "gcp.vertex_ai",
@@ -0,0 +1,34 @@
1
+ import type { Part } from "../genai-content.js";
2
+ /** Map a Responses `message.content` (string or list) to spec parts. */
3
+ export declare function messageContentToParts(content: unknown): Part[];
4
+ /** Map ONE Responses `input` item to `[eventName, role, parts]`, or null. */
5
+ export declare function inputItemToEvent(item: unknown): [string, string, Part[]] | null;
6
+ /** Map ONE `response.output` item to the assistant's choice parts. */
7
+ export declare function outputItemToChoiceParts(item: unknown): Part[];
8
+ /** Normalize the `input` kwarg to a list of items (bare string → one user message). */
9
+ export declare function normalizeInput(input: unknown): unknown[];
10
+ /** Spec parts of the LAST `user` message in `input` (for parent propagation). */
11
+ export declare function lastUserParts(input: unknown): Part[] | undefined;
12
+ /** Every function_call `(name, call_id)` from a response.output (for tool linkage). */
13
+ export declare function iterFunctionCalls(output: unknown): Array<[string, string]>;
14
+ /** Derive a raw finish-reason string from a Responses response. */
15
+ export declare function deriveFinishReason(response: unknown): string | undefined;
16
+ /**
17
+ * True when a Responses response is in a terminal state (generation finished).
18
+ * A `background: true` request can return non-terminal (`"queued"` /
19
+ * `"in_progress"`) with no assistant message yet — we must NOT emit a terminal
20
+ * `gen_ai.choice` / finish reason for those. A response with no status is
21
+ * treated as terminal so we never silently drop telemetry for the common
22
+ * synchronous call (or minimal mocks).
23
+ */
24
+ export declare function isTerminalResponse(response: unknown): boolean;
25
+ /** Map a raw Responses status/derived reason to a spec choice finish reason. */
26
+ export declare function mapChoiceFinishReason(raw: string | undefined): string;
27
+ /** Serialize `input` → spec `gen_ai.input.messages` JSON string. */
28
+ export declare function toInputMessages(input: unknown): string;
29
+ /** Serialize Responses `instructions` → spec `system_instructions` JSON string. */
30
+ export declare function toSystemInstructions(instructions: unknown): string;
31
+ /** Serialize `response.output` → spec `gen_ai.output.messages` JSON string. */
32
+ export declare function toOutputMessages(output: unknown, finishReason: string | undefined): string;
33
+ export declare function safeJsonForTool(obj: unknown): string;
34
+ //# sourceMappingURL=openai-content.d.ts.map
@@ -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