Skip to content

Commit 62473a2

Browse files
feat(openai): standardize completions to indexed attribute format
Signed-off-by: Adrian Cole <[email protected]>
1 parent 4e441f2 commit 62473a2

File tree

11 files changed

+176
-12
lines changed

11 files changed

+176
-12
lines changed

python/instrumentation/openinference-instrumentation-openai/src/openinference/instrumentation/openai/_attributes/_responses_api.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,9 @@ def _get_attributes_from_response_input_param_function_call_output(
482482
if (call_id := obj.get("call_id")) is not None:
483483
yield f"{prefix}{MessageAttributes.MESSAGE_TOOL_CALL_ID}", call_id
484484
if (output := obj.get("output")) is not None:
485-
yield f"{prefix}{MessageAttributes.MESSAGE_CONTENT}", output
485+
# output can be str or complex type - serialize complex types to JSON
486+
output_value = output if isinstance(output, str) else safe_json_dumps(output)
487+
yield f"{prefix}{MessageAttributes.MESSAGE_CONTENT}", output_value
486488

487489
@classmethod
488490
@stop_on_exception
@@ -495,7 +497,9 @@ def _get_attributes_from_response_custom_tool_call_output_param(
495497
if (call_id := obj.get("call_id")) is not None:
496498
yield f"{prefix}{MessageAttributes.MESSAGE_TOOL_CALL_ID}", call_id
497499
if (output := obj.get("output")) is not None:
498-
yield f"{prefix}{MessageAttributes.MESSAGE_CONTENT}", output
500+
# output can be str or complex type - serialize complex types to JSON
501+
output_value = output if isinstance(output, str) else safe_json_dumps(output)
502+
yield f"{prefix}{MessageAttributes.MESSAGE_CONTENT}", output_value
499503

500504
@classmethod
501505
@stop_on_exception

python/instrumentation/openinference-instrumentation-openai/src/openinference/instrumentation/openai/_request_attributes_extractor.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232

3333
# TODO: Update to use SpanAttributes.EMBEDDING_INVOCATION_PARAMETERS when released in semconv
3434
_EMBEDDING_INVOCATION_PARAMETERS = "embedding.invocation_parameters"
35+
# TODO: Update to use SpanAttributes.COMPLETION_PROMPT when released in semconv
36+
_COMPLETION_PROMPT = "completion.prompt"
3537

3638
if TYPE_CHECKING:
3739
from openai.types import Completion, CreateEmbeddingResponse
@@ -226,13 +228,14 @@ def _get_attributes_from_completion_create_param(
226228

227229
model_prompt = params.get("prompt")
228230
if isinstance(model_prompt, str):
229-
yield SpanAttributes.LLM_PROMPTS, [model_prompt]
231+
yield f"{_COMPLETION_PROMPT}.0", model_prompt
230232
elif (
231233
isinstance(model_prompt, list)
232234
and model_prompt
233235
and all(isinstance(item, str) for item in model_prompt)
234236
):
235-
yield SpanAttributes.LLM_PROMPTS, model_prompt
237+
for index, prompt in enumerate(model_prompt):
238+
yield f"{_COMPLETION_PROMPT}.{index}", prompt
236239

237240

238241
def _get_attributes_from_embedding_create_param(

python/instrumentation/openinference-instrumentation-openai/src/openinference/instrumentation/openai/_response_attributes_extractor.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
ToolCallAttributes,
2626
)
2727

28+
# TODO: Update to use SpanAttributes.COMPLETION_TEXT when released in semconv
29+
_COMPLETION_TEXT = "completion.text"
30+
2831
if TYPE_CHECKING:
2932
from openai.types import Completion, CreateEmbeddingResponse
3033
from openai.types.chat import ChatCompletion
@@ -114,6 +117,13 @@ def _get_attributes_from_completion(
114117
if usage := getattr(completion, "usage", None):
115118
yield from self._get_attributes_from_completion_usage(usage)
116119

120+
if (choices := getattr(completion, "choices", None)) and isinstance(choices, Iterable):
121+
for choice in choices:
122+
if (index := getattr(choice, "index", None)) is None:
123+
continue
124+
if text := getattr(choice, "text", None):
125+
yield f"{_COMPLETION_TEXT}.{index}", text
126+
117127
def _get_attributes_from_create_embedding_response(
118128
self,
119129
response: "CreateEmbeddingResponse",

python/instrumentation/openinference-instrumentation-openai/tests/openinference/instrumentation/openai/test_instrumentor.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
List,
1717
Mapping,
1818
Optional,
19-
Sequence,
2019
Tuple,
2120
Union,
2221
cast,
@@ -47,6 +46,11 @@
4746
ToolCallAttributes,
4847
)
4948

49+
# TODO: Update to use SpanAttributes.COMPLETION_PROMPT when released in semconv
50+
_COMPLETION_PROMPT = "completion.prompt"
51+
# TODO: Update to use SpanAttributes.COMPLETION_TEXT when released in semconv
52+
_COMPLETION_TEXT = "completion.text"
53+
5054
for name, logger in logging.root.manager.loggerDict.items():
5155
if name.startswith("openinference.") and isinstance(logger, logging.Logger):
5256
logger.setLevel(logging.DEBUG)
@@ -300,8 +304,8 @@ def test_completions(
300304
prompt_template_version: str,
301305
prompt_template_variables: Dict[str, Any],
302306
) -> None:
303-
# SpanAttributes.LLM_PROMPTS is always a list, so coerce the input accordingly.
304-
prompt = prompt_input if isinstance(prompt_input, list) else [prompt_input]
307+
# Normalize prompt_input (string or list) to a list for iteration
308+
prompts = prompt_input if isinstance(prompt_input, list) else [prompt_input]
305309
output_texts: List[str] = completion_mock_stream[1] if is_stream else get_texts()
306310
invocation_parameters = {
307311
"stream": is_stream,
@@ -399,10 +403,14 @@ async def task() -> None:
399403
assert isinstance(attributes.pop(INPUT_VALUE, None), str)
400404
assert isinstance(attributes.pop(INPUT_MIME_TYPE, None), str)
401405
# Prompts are recorded in request phase, so present regardless of status
402-
assert list(cast(Sequence[str], attributes.pop(LLM_PROMPTS, None))) == prompt
406+
for i, prompt_text in enumerate(prompts):
407+
assert attributes.pop(f"{_COMPLETION_PROMPT}.{i}", None) == prompt_text
403408
if status_code == 200:
404409
assert isinstance(attributes.pop(OUTPUT_VALUE, None), str)
405410
assert isinstance(attributes.pop(OUTPUT_MIME_TYPE, None), str)
411+
# Check output completions
412+
for i, text in enumerate(output_texts):
413+
assert attributes.pop(f"{_COMPLETION_TEXT}.{i}", None) == text
406414
if not is_stream:
407415
# Usage is not available for streaming in general.
408416
assert attributes.pop(LLM_TOKEN_COUNT_TOTAL, None) == completion_usage["total_tokens"]

python/openinference-instrumentation/src/openinference/instrumentation/config.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,22 @@ def __aexit__(
8484
OPENINFERENCE_BASE64_IMAGE_MAX_LENGTH = "OPENINFERENCE_BASE64_IMAGE_MAX_LENGTH"
8585
# Limits characters of a base64 encoding of an image
8686
OPENINFERENCE_HIDE_PROMPTS = "OPENINFERENCE_HIDE_PROMPTS"
87-
# Hides LLM prompts
87+
# DEPRECATED: Use OPENINFERENCE_HIDE_COMPLETION_PROMPT instead
88+
OPENINFERENCE_HIDE_COMPLETION_PROMPT = "OPENINFERENCE_HIDE_COMPLETION_PROMPT"
89+
# Hides completion prompt
90+
OPENINFERENCE_HIDE_COMPLETION_TEXT = "OPENINFERENCE_HIDE_COMPLETION_TEXT"
91+
# Hides completion text
8892
REDACTED_VALUE = "__REDACTED__"
8993
# When a value is hidden, it will be replaced by this redacted value
9094

95+
# TODO: Update to use SpanAttributes constants when released in semconv
96+
_COMPLETION_PROMPT = "completion.prompt"
97+
_COMPLETION_TEXT = "completion.text"
98+
9199
DEFAULT_HIDE_LLM_INVOCATION_PARAMETERS = False
92100
DEFAULT_HIDE_PROMPTS = False
101+
DEFAULT_HIDE_COMPLETION_PROMPT = False
102+
DEFAULT_HIDE_COMPLETION_TEXT = False
93103
DEFAULT_HIDE_INPUTS = False
94104
DEFAULT_HIDE_OUTPUTS = False
95105

@@ -195,7 +205,23 @@ class TraceConfig:
195205
"default_value": DEFAULT_HIDE_PROMPTS,
196206
},
197207
)
198-
"""Hides LLM prompts"""
208+
"""Hides LLM prompts (DEPRECATED: use hide_completion_prompt)"""
209+
hide_completion_prompt: Optional[bool] = field(
210+
default=None,
211+
metadata={
212+
"env_var": OPENINFERENCE_HIDE_COMPLETION_PROMPT,
213+
"default_value": DEFAULT_HIDE_COMPLETION_PROMPT,
214+
},
215+
)
216+
"""Hides completion prompt"""
217+
hide_completion_text: Optional[bool] = field(
218+
default=None,
219+
metadata={
220+
"env_var": OPENINFERENCE_HIDE_COMPLETION_TEXT,
221+
"default_value": DEFAULT_HIDE_COMPLETION_TEXT,
222+
},
223+
)
224+
"""Hides completion text"""
199225
base64_image_max_length: Optional[int] = field(
200226
default=None,
201227
metadata={
@@ -206,6 +232,14 @@ class TraceConfig:
206232
"""Limits characters of a base64 encoding of an image"""
207233

208234
def __post_init__(self) -> None:
235+
# Track if hide_completion_prompt was explicitly set (not None)
236+
# to avoid overriding explicit user configuration
237+
hide_completion_prompt_was_explicit = self.hide_completion_prompt is not None
238+
# Also check if it's set via environment variable
239+
hide_completion_prompt_env_was_set = (
240+
os.getenv(OPENINFERENCE_HIDE_COMPLETION_PROMPT) is not None
241+
)
242+
209243
for f in fields(self):
210244
expected_type = get_args(f.type)[0]
211245
# Optional is Union[T,NoneType]. get_args()returns (T, NoneType).
@@ -216,6 +250,15 @@ def __post_init__(self) -> None:
216250
f.metadata["env_var"],
217251
f.metadata["default_value"],
218252
)
253+
# TODO: Remove this backward compatibility after deprecation period
254+
# Only apply backward compat if hide_completion_prompt wasn't explicitly set
255+
# via constructor argument or environment variable
256+
if (
257+
not hide_completion_prompt_was_explicit
258+
and not hide_completion_prompt_env_was_set
259+
and self.hide_prompts is True
260+
):
261+
object.__setattr__(self, "hide_completion_prompt", True)
219262

220263
def mask(
221264
self,
@@ -226,6 +269,10 @@ def mask(
226269
return None
227270
elif self.hide_prompts and key == SpanAttributes.LLM_PROMPTS:
228271
value = REDACTED_VALUE
272+
elif (
273+
self.hide_inputs or self.hide_completion_prompt
274+
) and _COMPLETION_PROMPT in key:
275+
value = REDACTED_VALUE
229276
elif self.hide_inputs and key == SpanAttributes.INPUT_VALUE:
230277
value = REDACTED_VALUE
231278
elif self.hide_inputs and key == SpanAttributes.INPUT_MIME_TYPE:
@@ -242,6 +289,10 @@ def mask(
242289
self.hide_outputs or self.hide_output_messages
243290
) and SpanAttributes.LLM_OUTPUT_MESSAGES in key:
244291
return None
292+
elif (
293+
self.hide_outputs or self.hide_completion_text
294+
) and _COMPLETION_TEXT in key:
295+
value = REDACTED_VALUE
245296
elif (
246297
self.hide_input_text
247298
and SpanAttributes.LLM_INPUT_MESSAGES in key

python/openinference-instrumentation/tests/test_config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,22 @@
1717
DEFAULT_BASE64_IMAGE_MAX_LENGTH,
1818
DEFAULT_HIDE_INPUT_IMAGES,
1919
DEFAULT_HIDE_INPUT_MESSAGES,
20+
DEFAULT_HIDE_COMPLETION_PROMPT,
2021
DEFAULT_HIDE_INPUT_TEXT,
2122
DEFAULT_HIDE_INPUTS,
2223
DEFAULT_HIDE_LLM_INVOCATION_PARAMETERS,
24+
DEFAULT_HIDE_COMPLETION_TEXT,
2325
DEFAULT_HIDE_OUTPUT_MESSAGES,
2426
DEFAULT_HIDE_OUTPUT_TEXT,
2527
DEFAULT_HIDE_OUTPUTS,
2628
DEFAULT_HIDE_PROMPTS,
2729
OPENINFERENCE_BASE64_IMAGE_MAX_LENGTH,
2830
OPENINFERENCE_HIDE_INPUT_IMAGES,
2931
OPENINFERENCE_HIDE_INPUT_MESSAGES,
32+
OPENINFERENCE_HIDE_COMPLETION_PROMPT,
3033
OPENINFERENCE_HIDE_INPUT_TEXT,
3134
OPENINFERENCE_HIDE_INPUTS,
35+
OPENINFERENCE_HIDE_COMPLETION_TEXT,
3236
OPENINFERENCE_HIDE_OUTPUT_MESSAGES,
3337
OPENINFERENCE_HIDE_OUTPUT_TEXT,
3438
OPENINFERENCE_HIDE_OUTPUTS,
@@ -49,6 +53,8 @@ def test_default_settings() -> None:
4953
assert config.hide_input_text == DEFAULT_HIDE_INPUT_TEXT
5054
assert config.hide_output_text == DEFAULT_HIDE_OUTPUT_TEXT
5155
assert config.hide_prompts == DEFAULT_HIDE_PROMPTS
56+
assert config.hide_completion_prompt == DEFAULT_HIDE_COMPLETION_PROMPT
57+
assert config.hide_completion_text == DEFAULT_HIDE_COMPLETION_TEXT
5258
assert config.base64_image_max_length == DEFAULT_BASE64_IMAGE_MAX_LENGTH
5359

5460

@@ -121,6 +127,8 @@ def test_attribute_priority(k: str, in_memory_span_exporter: InMemorySpanExporte
121127
@pytest.mark.parametrize("hide_input_text", [False, True])
122128
@pytest.mark.parametrize("hide_output_text", [False, True])
123129
@pytest.mark.parametrize("hide_prompts", [False, True])
130+
@pytest.mark.parametrize("hide_completion_prompt", [False, True])
131+
@pytest.mark.parametrize("hide_completion_text", [False, True])
124132
@pytest.mark.parametrize("base64_image_max_length", [10_000])
125133
def test_settings_from_env_vars_and_code(
126134
hide_inputs: bool,
@@ -131,6 +139,8 @@ def test_settings_from_env_vars_and_code(
131139
hide_input_text: bool,
132140
hide_output_text: bool,
133141
hide_prompts: bool,
142+
hide_completion_prompt: bool,
143+
hide_completion_text: bool,
134144
base64_image_max_length: int,
135145
monkeypatch: pytest.MonkeyPatch,
136146
) -> None:
@@ -141,6 +151,8 @@ def test_settings_from_env_vars_and_code(
141151
monkeypatch.setenv(OPENINFERENCE_HIDE_OUTPUT_MESSAGES, str(hide_output_messages))
142152
monkeypatch.setenv(OPENINFERENCE_HIDE_INPUT_IMAGES, str(hide_input_images))
143153
monkeypatch.setenv(OPENINFERENCE_HIDE_PROMPTS, str(hide_prompts))
154+
monkeypatch.setenv(OPENINFERENCE_HIDE_COMPLETION_PROMPT, str(hide_completion_prompt))
155+
monkeypatch.setenv(OPENINFERENCE_HIDE_COMPLETION_TEXT, str(hide_completion_text))
144156
monkeypatch.setenv(OPENINFERENCE_HIDE_INPUT_TEXT, str(hide_input_text))
145157
monkeypatch.setenv(OPENINFERENCE_HIDE_OUTPUT_TEXT, str(hide_output_text))
146158
monkeypatch.setenv(OPENINFERENCE_BASE64_IMAGE_MAX_LENGTH, str(base64_image_max_length))
@@ -154,6 +166,8 @@ def test_settings_from_env_vars_and_code(
154166
assert config.hide_input_text is parse_bool_from_env(OPENINFERENCE_HIDE_INPUT_TEXT)
155167
assert config.hide_output_text is parse_bool_from_env(OPENINFERENCE_HIDE_OUTPUT_TEXT)
156168
assert config.hide_prompts is parse_bool_from_env(OPENINFERENCE_HIDE_PROMPTS)
169+
assert config.hide_completion_prompt is parse_bool_from_env(OPENINFERENCE_HIDE_COMPLETION_PROMPT)
170+
assert config.hide_completion_text is parse_bool_from_env(OPENINFERENCE_HIDE_COMPLETION_TEXT)
157171
assert config.base64_image_max_length == int(
158172
os.getenv(OPENINFERENCE_BASE64_IMAGE_MAX_LENGTH, default=-1)
159173
)
@@ -169,6 +183,8 @@ def test_settings_from_env_vars_and_code(
169183
new_hide_input_text = not hide_input_text
170184
new_hide_output_text = not hide_output_text
171185
new_hide_prompts = not hide_prompts
186+
new_hide_completion_prompt = not hide_completion_prompt
187+
new_hide_completion_text = not hide_completion_text
172188
config = TraceConfig(
173189
hide_inputs=new_hide_inputs,
174190
hide_outputs=new_hide_outputs,
@@ -178,6 +194,8 @@ def test_settings_from_env_vars_and_code(
178194
hide_input_text=new_hide_input_text,
179195
hide_output_text=new_hide_output_text,
180196
hide_prompts=new_hide_prompts,
197+
hide_completion_prompt=new_hide_completion_prompt,
198+
hide_completion_text=new_hide_completion_text,
181199
base64_image_max_length=new_base64_image_max_length,
182200
)
183201
assert config.hide_inputs is new_hide_inputs
@@ -188,6 +206,8 @@ def test_settings_from_env_vars_and_code(
188206
assert config.hide_input_text is new_hide_input_text
189207
assert config.hide_output_text is new_hide_output_text
190208
assert config.hide_prompts is new_hide_prompts
209+
assert config.hide_completion_prompt is new_hide_completion_prompt
210+
assert config.hide_completion_text is new_hide_completion_text
191211
assert config.base64_image_max_length == new_base64_image_max_length
192212

193213

python/openinference-semantic-conventions/src/openinference/semconv/trace/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ class SpanAttributes:
5757
LLM_PROMPTS = "llm.prompts"
5858
"""
5959
Prompts provided to a completions API.
60+
DEPRECATED: Use COMPLETION_PROMPT instead for indexed format.
61+
"""
62+
COMPLETION_PROMPT = "completion.prompt"
63+
"""
64+
Prompt(s) provided to a completions API. Use indexed format for arrays.
65+
Maps to the 'prompt' field in the request (e.g., request.prompt or request.prompt[0]).
66+
Use format: completion.prompt.N
67+
"""
68+
COMPLETION_TEXT = "completion.text"
69+
"""
70+
Text Choice(s) returned from a completions API. Use indexed format for arrays.
71+
Maps to the 'choices' array in the response (e.g., response.choices[0].text).
72+
Use format: completion.text.N
6073
"""
6174
LLM_PROMPT_TEMPLATE = "llm.prompt_template.template"
6275
"""

python/openinference-semantic-conventions/tests/openinference/semconv/test_attributes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ def test_nesting(self) -> None:
9797
"mime_type": SpanAttributes.INPUT_MIME_TYPE,
9898
"value": SpanAttributes.INPUT_VALUE,
9999
},
100+
"completion": {
101+
"input": SpanAttributes.COMPLETION_INPUT,
102+
"output": SpanAttributes.COMPLETION_OUTPUT,
103+
},
100104
"llm": {
101105
"cost": {
102106
"completion": SpanAttributes.LLM_COST_COMPLETION,

spec/configuration.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ The possible settings are:
1515
| OPENINFERENCE_HIDE_OUTPUT_MESSAGES | Hides all output messages (independent of HIDE_OUTPUTS) | bool | False |
1616
| OPENINFERENCE_HIDE_INPUT_IMAGES | Hides images from input messages (only applies when input messages are not already hidden) | bool | False |
1717
| OPENINFERENCE_HIDE_INPUT_TEXT | Hides text from input messages (only applies when input messages are not already hidden) | bool | False |
18-
| OPENINFERENCE_HIDE_PROMPTS | Hides LLM prompts | bool | False |
18+
| OPENINFERENCE_HIDE_PROMPTS | DEPRECATED: Use OPENINFERENCE_HIDE_COMPLETION_PROMPT instead | bool | False |
19+
| OPENINFERENCE_HIDE_COMPLETION_PROMPT | Hides completion prompt (for completions API) | bool | False |
1920
| OPENINFERENCE_HIDE_OUTPUT_TEXT | Hides text from output messages (only applies when output messages are not already hidden) | bool | False |
21+
| OPENINFERENCE_HIDE_COMPLETION_TEXT | Hides completion text (for completions API) | bool | False |
2022
| OPENINFERENCE_HIDE_EMBEDDING_VECTORS | Hides embedding vectors | bool | False |
2123
| OPENINFERENCE_BASE64_IMAGE_MAX_LENGTH | Limits characters of a base64 encoding of an image | int | 32,000 |
2224

@@ -51,7 +53,9 @@ If you are working in Python, and want to set up a configuration different than
5153
hide_output_text=...,
5254
hide_embedding_vectors=...,
5355
base64_image_max_length=...,
54-
hide_prompts=...,
56+
hide_prompts=..., # DEPRECATED: use hide_completion_prompt
57+
hide_completion_prompt=...,
58+
hide_completion_text=...,
5559
)
5660

5761
from openinference.instrumentation.openai import OpenAIInstrumentor

0 commit comments

Comments
 (0)