Skip to content

Commit 6d00a12

Browse files
committed
feat(openai): Instrument structured outputs (chat.completions.parse)
Fixes #3449
1 parent 7f107df commit 6d00a12

9 files changed

Lines changed: 888 additions & 4 deletions

File tree

instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@
7272
)
7373

7474

75+
def _is_parse_supported():
76+
"""Check if the parse() method is available on the Completions class.
77+
78+
The parse() method for structured outputs was added in openai >= 1.40.0.
79+
"""
80+
try:
81+
from openai.resources.chat.completions import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415
82+
Completions,
83+
)
84+
85+
return hasattr(Completions, "parse")
86+
except ImportError:
87+
return False
88+
89+
7590
class OpenAIInstrumentor(BaseInstrumentor):
7691
def __init__(self):
7792
self._meter = None
@@ -159,10 +174,44 @@ def _instrument(self, **kwargs):
159174
),
160175
)
161176

177+
# parse() wraps create() internally in the OpenAI SDK and returns a
178+
# ParsedChatCompletion. The telemetry-relevant fields (model, usage,
179+
# choices, finish_reason) are identical to ChatCompletion, so the
180+
# existing create() wrappers handle it correctly.
181+
self._parse_supported = _is_parse_supported()
182+
if self._parse_supported:
183+
wrap_function_wrapper(
184+
module="openai.resources.chat.completions",
185+
name="Completions.parse",
186+
wrapper=(
187+
chat_completions_create_v_new(handler, content_mode)
188+
if latest_experimental_enabled
189+
else chat_completions_create_v_old(
190+
tracer, logger, instruments, is_content_enabled()
191+
)
192+
),
193+
)
194+
195+
wrap_function_wrapper(
196+
module="openai.resources.chat.completions",
197+
name="AsyncCompletions.parse",
198+
wrapper=(
199+
async_chat_completions_create_v_new(handler, content_mode)
200+
if latest_experimental_enabled
201+
else async_chat_completions_create_v_old(
202+
tracer, logger, instruments, is_content_enabled()
203+
)
204+
),
205+
)
206+
162207
def _uninstrument(self, **kwargs):
163208
import openai # pylint: disable=import-outside-toplevel # noqa: PLC0415
164209

165210
unwrap(openai.resources.chat.completions.Completions, "create")
166211
unwrap(openai.resources.chat.completions.AsyncCompletions, "create")
167212
unwrap(openai.resources.embeddings.Embeddings, "create")
168213
unwrap(openai.resources.embeddings.AsyncEmbeddings, "create")
214+
215+
if self._parse_supported:
216+
unwrap(openai.resources.chat.completions.Completions, "parse")
217+
unwrap(openai.resources.chat.completions.AsyncCompletions, "parse")

instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,8 +275,11 @@ def get_llm_request_attributes(
275275
else GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT
276276
)
277277
if (response_format := kwargs.get("response_format")) is not None:
278-
# response_format may be string or object with a string in the `type` key
279-
if isinstance(response_format, Mapping):
278+
# response_format may be string, object with a string in the `type` key,
279+
# or a type (e.g. Pydantic model class used with parse())
280+
if isinstance(response_format, type):
281+
attributes[request_response_format_attr_key] = "json_schema"
282+
elif isinstance(response_format, Mapping):
280283
if (
281284
response_format_type := response_format.get("type")
282285
) is not None:
@@ -369,8 +372,11 @@ def create_chat_invocation(
369372
if (
370373
response_format := get_value(kwargs.get("response_format"))
371374
) is not None:
372-
# response_format may be string or object with a string in the `type` key
373-
if isinstance(response_format, Mapping):
375+
# response_format may be string, object with a string in the `type` key,
376+
# or a type (e.g. Pydantic model class used with parse())
377+
if isinstance(response_format, type):
378+
attributes[GenAIAttributes.GEN_AI_OUTPUT_TYPE] = "json_schema"
379+
elif isinstance(response_format, Mapping):
374380
if (
375381
response_format_type := get_value(response_format.get("type"))
376382
) is not None:
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
interactions:
2+
- request:
3+
body: |-
4+
{
5+
"messages": [
6+
{
7+
"role": "user",
8+
"content": "Extract the event information from: Team Meeting on 2024-01-15 with Alice and Bob"
9+
}
10+
],
11+
"model": "gpt-4o-mini",
12+
"response_format": {
13+
"type": "json_schema",
14+
"json_schema": {
15+
"name": "CalendarEvent",
16+
"strict": true,
17+
"schema": {
18+
"type": "object",
19+
"properties": {
20+
"name": {"type": "string"},
21+
"date": {"type": "string"},
22+
"participants": {"items": {"type": "string"}, "type": "array"}
23+
},
24+
"required": ["name", "date", "participants"],
25+
"additionalProperties": false
26+
}
27+
}
28+
}
29+
}
30+
headers:
31+
accept:
32+
- application/json
33+
accept-encoding:
34+
- gzip, deflate
35+
authorization:
36+
- Bearer test_openai_api_key
37+
connection:
38+
- keep-alive
39+
content-type:
40+
- application/json
41+
host:
42+
- api.openai.com
43+
user-agent:
44+
- OpenAI/Python 1.54.3
45+
x-stainless-async:
46+
- async:asyncio
47+
x-stainless-lang:
48+
- python
49+
method: POST
50+
uri: https://api.openai.com/v1/chat/completions
51+
response:
52+
body:
53+
string: |-
54+
{
55+
"id": "chatcmpl-structured-test-004",
56+
"object": "chat.completion",
57+
"created": 1731368630,
58+
"model": "gpt-4o-mini-2024-07-18",
59+
"choices": [
60+
{
61+
"index": 0,
62+
"message": {
63+
"role": "assistant",
64+
"content": "{\"name\": \"Team Meeting\", \"date\": \"2024-01-15\", \"participants\": [\"Alice\", \"Bob\"]}",
65+
"refusal": null
66+
},
67+
"logprobs": null,
68+
"finish_reason": "stop"
69+
}
70+
],
71+
"usage": {
72+
"prompt_tokens": 50,
73+
"completion_tokens": 30,
74+
"total_tokens": 80,
75+
"prompt_tokens_details": {
76+
"cached_tokens": 0,
77+
"audio_tokens": 0
78+
},
79+
"completion_tokens_details": {
80+
"reasoning_tokens": 0,
81+
"audio_tokens": 0,
82+
"accepted_prediction_tokens": 0,
83+
"rejected_prediction_tokens": 0
84+
}
85+
},
86+
"system_fingerprint": "fp_0ba0d124f1"
87+
}
88+
headers:
89+
CF-Cache-Status:
90+
- DYNAMIC
91+
Connection:
92+
- keep-alive
93+
Content-Type:
94+
- application/json
95+
Date:
96+
- Mon, 11 Nov 2024 23:43:50 GMT
97+
Server:
98+
- cloudflare
99+
Set-Cookie: test_set_cookie
100+
Transfer-Encoding:
101+
- chunked
102+
X-Content-Type-Options:
103+
- nosniff
104+
content-length:
105+
- '800'
106+
openai-organization: test_openai_org_id
107+
openai-processing-ms:
108+
- '350'
109+
openai-version:
110+
- '2020-10-01'
111+
x-request-id:
112+
- req_structured_test_004
113+
status:
114+
code: 200
115+
message: OK
116+
version: 1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
interactions:
2+
- request:
3+
body: |-
4+
{
5+
"messages": [
6+
{
7+
"role": "user",
8+
"content": "Extract the event information from: Team Meeting on 2024-01-15 with Alice and Bob"
9+
}
10+
],
11+
"model": "gpt-4o-mini",
12+
"response_format": {
13+
"type": "json_schema",
14+
"json_schema": {
15+
"name": "CalendarEvent",
16+
"strict": true,
17+
"schema": {
18+
"type": "object",
19+
"properties": {
20+
"name": {"type": "string"},
21+
"date": {"type": "string"},
22+
"participants": {"items": {"type": "string"}, "type": "array"}
23+
},
24+
"required": ["name", "date", "participants"],
25+
"additionalProperties": false
26+
}
27+
}
28+
}
29+
}
30+
headers:
31+
accept:
32+
- application/json
33+
accept-encoding:
34+
- gzip, deflate
35+
authorization:
36+
- Bearer test_openai_api_key
37+
connection:
38+
- keep-alive
39+
content-type:
40+
- application/json
41+
host:
42+
- api.openai.com
43+
user-agent:
44+
- OpenAI/Python 1.54.3
45+
x-stainless-async:
46+
- async:asyncio
47+
x-stainless-lang:
48+
- python
49+
method: POST
50+
uri: https://api.openai.com/v1/chat/completions
51+
response:
52+
body:
53+
string: |-
54+
{
55+
"id": "chatcmpl-structured-test-003",
56+
"object": "chat.completion",
57+
"created": 1731368630,
58+
"model": "gpt-4o-mini-2024-07-18",
59+
"choices": [
60+
{
61+
"index": 0,
62+
"message": {
63+
"role": "assistant",
64+
"content": "{\"name\": \"Team Meeting\", \"date\": \"2024-01-15\", \"participants\": [\"Alice\", \"Bob\"]}",
65+
"refusal": null
66+
},
67+
"logprobs": null,
68+
"finish_reason": "stop"
69+
}
70+
],
71+
"usage": {
72+
"prompt_tokens": 50,
73+
"completion_tokens": 30,
74+
"total_tokens": 80,
75+
"prompt_tokens_details": {
76+
"cached_tokens": 0,
77+
"audio_tokens": 0
78+
},
79+
"completion_tokens_details": {
80+
"reasoning_tokens": 0,
81+
"audio_tokens": 0,
82+
"accepted_prediction_tokens": 0,
83+
"rejected_prediction_tokens": 0
84+
}
85+
},
86+
"system_fingerprint": "fp_0ba0d124f1"
87+
}
88+
headers:
89+
CF-Cache-Status:
90+
- DYNAMIC
91+
Connection:
92+
- keep-alive
93+
Content-Type:
94+
- application/json
95+
Date:
96+
- Mon, 11 Nov 2024 23:43:50 GMT
97+
Server:
98+
- cloudflare
99+
Set-Cookie: test_set_cookie
100+
Transfer-Encoding:
101+
- chunked
102+
X-Content-Type-Options:
103+
- nosniff
104+
content-length:
105+
- '800'
106+
openai-organization: test_openai_org_id
107+
openai-processing-ms:
108+
- '350'
109+
openai-version:
110+
- '2020-10-01'
111+
x-request-id:
112+
- req_structured_test_003
113+
status:
114+
code: 200
115+
message: OK
116+
version: 1

0 commit comments

Comments
 (0)