Skip to content

Commit 23fa4d9

Browse files
fix: standardize finish_reason fallback to empty string across OpenAI, Anthropic, LangChain
Aligns all packages with the Bedrock convention: when finish_reason is unknown/None, use "" (empty string) instead of fabricating "stop" (OpenAI) or omitting the key entirely (Anthropic, LangChain). - OpenAI chat_wrappers: `or "stop"` → `or ""` - OpenAI completion_wrappers: `or "stop"` → `or ""` - Anthropic span_utils: conditional `if mapped` → always set with `""` fallback (3 spots) - LangChain span_utils: conditional `if fr` → always include `finish_reason` with `""` fallback - Updated tests in all three packages to verify the new behavior
1 parent 0a25803 commit 23fa4d9

7 files changed

Lines changed: 95 additions & 16 deletions

File tree

packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,7 @@ def _build_output_messages_from_content(response):
257257
"parts": [{"type": "text", "content": response.get("completion")}],
258258
}
259259
mapped = _map_finish_reason(response.get("stop_reason"))
260-
if mapped:
261-
msg["finish_reason"] = mapped
260+
msg["finish_reason"] = mapped if mapped else ""
262261
return [msg]
263262

264263
if not response.get("content"):
@@ -296,8 +295,7 @@ def _build_output_messages_from_content(response):
296295
"parts": parts,
297296
}
298297
mapped = _map_finish_reason(response.get("stop_reason"))
299-
if mapped:
300-
msg["finish_reason"] = mapped
298+
msg["finish_reason"] = mapped if mapped else ""
301299
return [msg]
302300

303301

@@ -449,8 +447,7 @@ def set_streaming_response_attributes(span, complete_response_events):
449447
"role": "assistant",
450448
"parts": parts,
451449
}
452-
if finish_reasons:
453-
msg["finish_reason"] = finish_reasons[-1]
450+
msg["finish_reason"] = finish_reasons[-1] if finish_reasons else ""
454451
output_messages = [msg]
455452
set_span_attribute(
456453
span,

packages/opentelemetry-instrumentation-anthropic/tests/test_semconv_span_attrs.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,34 @@ def test_streaming_finish_reasons_set_when_content_tracing_disabled():
375375
assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in span.attributes
376376

377377

378+
def test_finish_reason_empty_string_when_none():
379+
"""finish_reason must be '' (not omitted) when stop_reason is None (Bedrock convention)."""
380+
span = make_span()
381+
response = _make_response([_make_text_block("Hello")], stop_reason=None)
382+
set_response_attributes(span, response)
383+
384+
output = json.loads(span.attributes[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES])
385+
assert len(output) == 1
386+
assert "finish_reason" in output[0], "finish_reason key must always be present"
387+
assert output[0]["finish_reason"] == "", (
388+
f"Expected '' for missing stop_reason, got '{output[0]['finish_reason']}'"
389+
)
390+
391+
392+
def test_streaming_finish_reason_empty_string_when_none():
393+
"""Streaming: finish_reason must be '' when no finish_reason in events."""
394+
span = make_span()
395+
events = [{"type": "text", "text": "Hello", "index": 0}]
396+
set_streaming_response_attributes(span, events)
397+
398+
raw = span.attributes.get(GenAIAttributes.GEN_AI_OUTPUT_MESSAGES)
399+
if raw:
400+
output = json.loads(raw)
401+
assert output[0]["finish_reason"] == "", (
402+
f"Expected '' for missing streaming finish_reason, got '{output[0]['finish_reason']}'"
403+
)
404+
405+
378406
def test_output_messages_tool_use_response():
379407
"""Tool use in the response should appear as tool_call parts."""
380408
span = make_span()
@@ -1009,8 +1037,8 @@ def test_image_without_upload_produces_blob_part():
10091037

10101038

10111039
def test_streaming_finish_reason_null_omitted_from_json():
1012-
"""When no finish_reason is available, the key must be omitted from
1013-
gen_ai.output.messages JSON — NOT serialized as null."""
1040+
"""When no finish_reason is available, the key must be present with empty
1041+
string value — NOT serialized as null, NOT omitted (Bedrock convention)."""
10141042
span = make_span()
10151043
# Event with no finish_reason key at all
10161044
events = [{"type": "text", "text": "Hello world", "index": 0}]
@@ -1020,8 +1048,8 @@ def test_streaming_finish_reason_null_omitted_from_json():
10201048
assert len(output) == 1
10211049
assert output[0]["role"] == "assistant"
10221050
assert output[0]["parts"] == [{"type": "text", "content": "Hello world"}]
1023-
# finish_reason key must be absent, not null
1024-
assert "finish_reason" not in output[0]
1051+
# finish_reason key must be present with empty string fallback
1052+
assert output[0]["finish_reason"] == ""
10251053

10261054

10271055
def test_streaming_finish_reason_none_does_not_set_span_attr():

packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -369,9 +369,7 @@ def set_chat_response(span: Span, response: LLMResult) -> None:
369369
if tool_calls and isinstance(tool_calls, list):
370370
parts.extend(_tool_calls_to_parts(tool_calls))
371371

372-
msg_obj = {"role": role, "parts": parts}
373-
if fr:
374-
msg_obj["finish_reason"] = fr
372+
msg_obj = {"role": role, "parts": parts, "finish_reason": fr if fr else ""}
375373
output_messages.append(msg_obj)
376374

377375
if output_messages:

packages/opentelemetry-instrumentation-langchain/tests/test_finish_reasons.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def test_no_finish_reason_omits_attribute(self, mock_span, monkeypatch):
111111
assert GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS not in mock_span.attributes
112112

113113
output = json.loads(mock_span.attributes[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES])
114-
assert "finish_reason" not in output[0]
114+
assert output[0]["finish_reason"] == ""
115115

116116
def test_empty_generation_info_omits_attribute(self, mock_span, monkeypatch):
117117
monkeypatch.setattr(

packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ def _set_output_messages(span, choices):
603603
"name": fc_name,
604604
"arguments": _parse_arguments(fc_args),
605605
})
606-
fr = _map_finish_reason(choice.get("finish_reason")) or "stop"
606+
fr = _map_finish_reason(choice.get("finish_reason")) or ""
607607
entry = {"role": "assistant", "parts": parts, "finish_reason": fr}
608608
if content_filter_results:
609609
entry["content_filter_results"] = content_filter_results

packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/completion_wrappers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def _set_output_messages(span, choices):
190190

191191
messages = []
192192
for choice in choices:
193-
fr = _map_finish_reason(choice.get("finish_reason")) or "stop"
193+
fr = _map_finish_reason(choice.get("finish_reason")) or ""
194194
entry = {
195195
"role": "assistant",
196196
"parts": [{"content": choice.get("text"), "type": "text"}],

packages/opentelemetry-instrumentation-openai/tests/traces/test_semconv_compliance.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,62 @@ def test_refusal_captured_in_output_messages(self, mock_span):
454454
)
455455

456456

457+
# ---------------------------------------------------------------------------
458+
# finish_reason fallback: must be "" when unknown, not fabricated "stop"
459+
# ---------------------------------------------------------------------------
460+
461+
class TestFinishReasonFallback:
462+
"""finish_reason in output messages must be '' when unknown (Bedrock convention)."""
463+
464+
def test_chat_missing_finish_reason_uses_empty_string(self, mock_span):
465+
choices = [
466+
{
467+
"message": {"role": "assistant", "content": "Hello"},
468+
"finish_reason": None,
469+
}
470+
]
471+
_set_output_messages(mock_span, choices)
472+
result = _get_output_messages(mock_span)
473+
assert result[0]["finish_reason"] == "", (
474+
f"Expected '' for missing finish_reason, got '{result[0]['finish_reason']}'"
475+
)
476+
477+
def test_chat_present_finish_reason_preserved(self, mock_span):
478+
choices = [
479+
{
480+
"message": {"role": "assistant", "content": "Hi"},
481+
"finish_reason": "stop",
482+
}
483+
]
484+
_set_output_messages(mock_span, choices)
485+
result = _get_output_messages(mock_span)
486+
assert result[0]["finish_reason"] == "stop"
487+
488+
def test_chat_tool_calls_finish_reason_mapped(self, mock_span):
489+
choices = [
490+
{
491+
"message": {"role": "assistant", "content": None, "tool_calls": [
492+
{"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
493+
]},
494+
"finish_reason": "tool_calls",
495+
}
496+
]
497+
_set_output_messages(mock_span, choices)
498+
result = _get_output_messages(mock_span)
499+
assert result[0]["finish_reason"] == "tool_call"
500+
501+
def test_completion_missing_finish_reason_uses_empty_string(self, mock_span):
502+
from opentelemetry.instrumentation.openai.shared.completion_wrappers import (
503+
_set_output_messages as _set_completion_output_messages,
504+
)
505+
choices = [{"text": "Hello world", "finish_reason": None}]
506+
_set_completion_output_messages(mock_span, choices)
507+
result = _get_output_messages(mock_span)
508+
assert result[0]["finish_reason"] == "", (
509+
f"Expected '' for missing finish_reason, got '{result[0]['finish_reason']}'"
510+
)
511+
512+
457513
# ---------------------------------------------------------------------------
458514
# P2-4: Metrics must map finish reason values correctly
459515
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)