@arizeai/openinference-instrumentation-openai 4.3.2 → 4.4.0

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 (45) hide show
  1. package/README.md +21 -0
  2. package/dist/esm/decisionsAttributes.d.ts +35 -0
  3. package/dist/esm/decisionsAttributes.d.ts.map +1 -0
  4. package/dist/esm/decisionsAttributes.js +55 -0
  5. package/dist/esm/decisionsAttributes.js.map +1 -0
  6. package/dist/esm/instrumentation.d.ts.map +1 -1
  7. package/dist/esm/instrumentation.js +75 -0
  8. package/dist/esm/instrumentation.js.map +1 -1
  9. package/dist/esm/responsesAttributes.d.ts.map +1 -1
  10. package/dist/esm/responsesAttributes.js +6 -3
  11. package/dist/esm/responsesAttributes.js.map +1 -1
  12. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  13. package/dist/esm/version.d.ts +1 -1
  14. package/dist/esm/version.js +1 -1
  15. package/dist/esnext/decisionsAttributes.d.ts +35 -0
  16. package/dist/esnext/decisionsAttributes.d.ts.map +1 -0
  17. package/dist/esnext/decisionsAttributes.js +50 -0
  18. package/dist/esnext/decisionsAttributes.js.map +1 -0
  19. package/dist/esnext/instrumentation.d.ts.map +1 -1
  20. package/dist/esnext/instrumentation.js +72 -0
  21. package/dist/esnext/instrumentation.js.map +1 -1
  22. package/dist/esnext/responsesAttributes.d.ts.map +1 -1
  23. package/dist/esnext/responsesAttributes.js +6 -4
  24. package/dist/esnext/responsesAttributes.js.map +1 -1
  25. package/dist/esnext/tsconfig.esnext.tsbuildinfo +1 -1
  26. package/dist/esnext/version.d.ts +1 -1
  27. package/dist/esnext/version.js +1 -1
  28. package/dist/src/decisionsAttributes.d.ts +35 -0
  29. package/dist/src/decisionsAttributes.d.ts.map +1 -0
  30. package/dist/src/decisionsAttributes.js +54 -0
  31. package/dist/src/decisionsAttributes.js.map +1 -0
  32. package/dist/src/instrumentation.d.ts.map +1 -1
  33. package/dist/src/instrumentation.js +72 -0
  34. package/dist/src/instrumentation.js.map +1 -1
  35. package/dist/src/responsesAttributes.d.ts.map +1 -1
  36. package/dist/src/responsesAttributes.js +6 -4
  37. package/dist/src/responsesAttributes.js.map +1 -1
  38. package/dist/src/version.d.ts +1 -1
  39. package/dist/src/version.js +1 -1
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +2 -2
  42. package/src/decisionsAttributes.ts +74 -0
  43. package/src/instrumentation.ts +90 -0
  44. package/src/responsesAttributes.ts +7 -4
  45. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arizeai/openinference-instrumentation-openai",
3
- "version": "4.3.2",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "OpenInference instrumentation for OpenAI",
6
6
  "keywords": [],
@@ -38,7 +38,7 @@
38
38
  "@opentelemetry/sdk-trace-node": "^1.25.1",
39
39
  "@opentelemetry/semantic-conventions": "^1.25.1",
40
40
  "@types/node": "^20.14.11",
41
- "openai": "^6.7.0",
41
+ "openai": "^7.30.0",
42
42
  "typescript": "^7.0.2",
43
43
  "vitest": "^4.0.3",
44
44
  "zod": "^3.24.3"
@@ -0,0 +1,74 @@
1
+ import type { Attributes } from "@opentelemetry/api";
2
+ import type { Decision, DecisionCreateParams } from "openai/resources/decisions";
3
+
4
+ import {
5
+ getDecisionAttributes,
6
+ getInputAttributes,
7
+ getOutputAttributes,
8
+ safelyJSONStringify,
9
+ } from "@arizeai/openinference-core";
10
+ import {
11
+ DecisionProvider,
12
+ DecisionSystem,
13
+ MimeType,
14
+ } from "@arizeai/openinference-semantic-conventions";
15
+
16
+ /**
17
+ * Builds the OpenInference attributes for a Decisions API request.
18
+ *
19
+ * Decision spans identify the model under `decision.*` rather than `llm.*`,
20
+ * and carry the raw request as JSON on `input.value` instead of input
21
+ * messages: the interesting part of a decision request is the set of typed
22
+ * questions and candidate answers, which has no message equivalent.
23
+ *
24
+ * @param body - The `decisions.create` request body.
25
+ * @returns `decision.system`, `decision.provider`, `decision.request.model_name`,
26
+ * `decision.model_name` and the input attributes.
27
+ * @see https://github.com/Arize-ai/openinference/blob/main/spec/decision_spans.md
28
+ */
29
+ export function getDecisionsRequestAttributes(body: DecisionCreateParams): Attributes {
30
+ const input = safelyJSONStringify(body);
31
+ return {
32
+ ...getDecisionAttributes({
33
+ system: DecisionSystem.OPENAI,
34
+ provider: DecisionProvider.OPENAI,
35
+ requestModelName: body.model,
36
+ }),
37
+ ...(input != null ? getInputAttributes({ value: input, mimeType: MimeType.JSON }) : {}),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Builds the OpenInference attributes for a Decisions API response.
43
+ *
44
+ * Records the raw response as JSON on `output.value`, the model that served
45
+ * the decision as `decision.response.model_name` (and as `decision.model_name`,
46
+ * falling back to the requested model when the response omits it), and the
47
+ * reported usage as `decision.token_count.input` / `decision.token_count.output`.
48
+ *
49
+ * @param args - Attribute sources.
50
+ * @param args.response - The parsed `decisions.create` response.
51
+ * @param args.requestModelName - The model named in the request, used as the
52
+ * `decision.model_name` fallback.
53
+ * @returns The output and decision response attributes.
54
+ */
55
+ export function getDecisionsResponseAttributes({
56
+ response,
57
+ requestModelName,
58
+ }: {
59
+ response: Decision;
60
+ requestModelName?: string;
61
+ }): Attributes {
62
+ const output = safelyJSONStringify(response);
63
+ return {
64
+ ...(output != null ? getOutputAttributes({ value: output, mimeType: MimeType.JSON }) : {}),
65
+ ...getDecisionAttributes({
66
+ requestModelName,
67
+ responseModelName: response.model,
68
+ tokenCount: {
69
+ input: response.usage?.input_tokens,
70
+ output: response.usage?.output_tokens,
71
+ },
72
+ }),
73
+ };
74
+ }
@@ -21,6 +21,7 @@ import type {
21
21
  ChatCompletionMessageParam,
22
22
  } from "openai/resources/chat/completions";
23
23
  import type { CompletionCreateParamsBase } from "openai/resources/completions";
24
+ import type { Decision } from "openai/resources/decisions";
24
25
  import type {
25
26
  Response as ResponseType,
26
27
  ResponseCreateParamsBase,
@@ -38,6 +39,10 @@ import {
38
39
  SemanticConventions,
39
40
  } from "@arizeai/openinference-semantic-conventions";
40
41
 
42
+ import {
43
+ getDecisionsRequestAttributes,
44
+ getDecisionsResponseAttributes,
45
+ } from "./decisionsAttributes";
41
46
  import {
42
47
  consumeResponseStreamEvents,
43
48
  getResponsesInputMessagesAttributes,
@@ -590,6 +595,64 @@ export class OpenAIInstrumentation extends InstrumentationBase<typeof openai> {
590
595
  );
591
596
  }
592
597
 
598
+ // Patch decisions (if the patched module contains the Decisions interface, openai >= 7.30)
599
+ if (module.OpenAI.Decisions) {
600
+ type DecisionsCreateType = typeof module.OpenAI.Decisions.prototype.create;
601
+
602
+ this._wrap(
603
+ module.OpenAI.Decisions.prototype,
604
+ "create",
605
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
606
+ (original: DecisionsCreateType): any => {
607
+ return function patchedCreate(this: unknown, ...args: Parameters<DecisionsCreateType>) {
608
+ const body = args[0];
609
+ // A decision model scores caller-supplied answers rather than generating
610
+ // text, so this is a DECISION span with no llm.* attributes.
611
+ const span = instrumentation.oiTracer.startSpan(`OpenAI Decisions`, {
612
+ kind: SpanKind.INTERNAL,
613
+ attributes: {
614
+ [SemanticConventions.OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.DECISION,
615
+ ...getDecisionsRequestAttributes(body),
616
+ },
617
+ });
618
+ const execContext = getExecContext(span);
619
+ const recordError = (error: unknown) => {
620
+ span.recordException(error instanceof Error ? error : String(error));
621
+ span.setStatus({
622
+ code: SpanStatusCode.ERROR,
623
+ message: error instanceof Error ? error.message : String(error),
624
+ });
625
+ span.end();
626
+ };
627
+ const execPromise = safeExecuteInTheMiddle(
628
+ () => {
629
+ return context.with(trace.setSpan(execContext, span), () => {
630
+ return original.apply(this, args);
631
+ });
632
+ },
633
+ (error) => {
634
+ // Push the error to the span
635
+ if (error) {
636
+ recordError(error);
637
+ }
638
+ },
639
+ );
640
+ const wrappedPromiseThen = (result: Decision) => {
641
+ span.setAttributes(
642
+ getDecisionsResponseAttributes({ response: result, requestModelName: body.model }),
643
+ );
644
+ span.setStatus({ code: SpanStatusCode.OK });
645
+ span.end();
646
+ return result;
647
+ };
648
+ observeRejection(execPromise, recordError);
649
+ const wrappedPromise = invokeMaybeAPIPromise(execPromise, wrappedPromiseThen);
650
+ return context.bind(execContext, wrappedPromise);
651
+ };
652
+ },
653
+ );
654
+ }
655
+
593
656
  _isOpenInferencePatched = true;
594
657
  _patchedModules.add(module.OpenAI);
595
658
  this.patchedModuleExports.set(module.OpenAI, module);
@@ -613,6 +676,12 @@ export class OpenAIInstrumentation extends InstrumentationBase<typeof openai> {
613
676
  this._unwrap(moduleExports.OpenAI.Chat.Completions.prototype, "create");
614
677
  this._unwrap(moduleExports.OpenAI.Completions.prototype, "create");
615
678
  this._unwrap(moduleExports.OpenAI.Embeddings.prototype, "create");
679
+ if (moduleExports.OpenAI.Responses) {
680
+ this._unwrap(moduleExports.OpenAI.Responses.prototype, "create");
681
+ }
682
+ if (moduleExports.OpenAI.Decisions) {
683
+ this._unwrap(moduleExports.OpenAI.Decisions.prototype, "create");
684
+ }
616
685
 
617
686
  // Keyed the same way patch() keys it, so a re-patch is possible after.
618
687
  _patchedModules.delete(moduleExports.OpenAI);
@@ -1037,6 +1106,27 @@ function isAPIPromise<T>(promise: unknown): promise is APIPromise<T> {
1037
1106
  return promise instanceof APIPromise;
1038
1107
  }
1039
1108
 
1109
+ /**
1110
+ * Calls `onRejected` if the SDK call rejects, so the span can be ended with an
1111
+ * error status.
1112
+ *
1113
+ * {@link invokeMaybeAPIPromise} only sees fulfilled values, so without this a
1114
+ * failed request would leave its span open. For an APIPromise the rejection is
1115
+ * observed through `asResponse()`, which settles with the underlying HTTP
1116
+ * promise and never parses the body: parsing it here as well would consume the
1117
+ * body before the caller reads it.
1118
+ *
1119
+ * @param promise - The value returned by the original SDK method
1120
+ * @param onRejected - Called with the rejection reason
1121
+ */
1122
+ function observeRejection(promise: unknown, onRejected: (error: unknown) => void): void {
1123
+ if (isAPIPromise<unknown>(promise)) {
1124
+ void promise.asResponse().then(undefined, onRejected);
1125
+ } else if (promise instanceof Promise) {
1126
+ void promise.then(undefined, onRejected);
1127
+ }
1128
+ }
1129
+
1040
1130
  /**
1041
1131
  * Invokes the thennable of a promise or an APIPromise.
1042
1132
  *
@@ -20,7 +20,7 @@ import { getNumberProperty } from "./typeUtils";
20
20
  * @returns The attributes for the item
21
21
  */
22
22
  function getResponseItemAttributes(
23
- item: Exclude<ResponseInputItem | ResponseOutputItem, { role: string }>,
23
+ item: Exclude<ResponseInputItem | ResponseOutputItem, { role: string; content: unknown }>,
24
24
  prefix = "",
25
25
  ): Attributes {
26
26
  const attributes: Attributes = {};
@@ -40,13 +40,14 @@ function getResponseItemAttributes(
40
40
  }
41
41
  case "function_call_output": {
42
42
  attributes[`${prefix}${SemanticConventions.MESSAGE_ROLE}`] = "tool";
43
- attributes[`${prefix}${SemanticConventions.MESSAGE_TOOL_CALL_ID}`] = item.call_id;
43
+ attributes[`${prefix}${SemanticConventions.MESSAGE_TOOL_CALL_ID}`] =
44
+ item.call_id ?? undefined;
44
45
  if (typeof item.output === "string") {
45
46
  attributes[`${prefix}${SemanticConventions.MESSAGE_CONTENT}`] = item.output;
46
47
  } else {
47
48
  // TODO(2410): figure out how to serialize the list of tools
48
49
  attributes[`${prefix}${SemanticConventions.MESSAGE_CONTENT}`] =
49
- safelyJSONStringify(item.output) || undefined;
50
+ safelyJSONStringify(item.output) ?? undefined;
50
51
  }
51
52
 
52
53
  break;
@@ -131,7 +132,9 @@ function getResponseItemMessageAttributes(
131
132
  typeof itemMessage === "string"
132
133
  ? ({ content: itemMessage, role: "user" } satisfies ResponseInputItem)
133
134
  : itemMessage;
134
- if (!("role" in message)) {
135
+ // Items such as `additional_tools` carry a role but no content, and are
136
+ // handled with the other non-message items.
137
+ if (!("role" in message) || !("content" in message)) {
135
138
  return getResponseItemAttributes(message, prefix);
136
139
  }
137
140
  const role = message.role;
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // this is an auto-generated file. see js/scripts/version-update.js
2
- export const VERSION = "4.3.2";
2
+ export const VERSION = "4.4.0";