Skip to content

Commit 2af70d5

Browse files
committed
fix: #885
1 parent e090747 commit 2af70d5

File tree

9 files changed

+3002
-2407
lines changed

9 files changed

+3002
-2407
lines changed

pydantic_ai_slim/pydantic_ai/messages.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@
5252
DocumentFormat: TypeAlias = Literal['csv', 'doc', 'docx', 'html', 'md', 'pdf', 'txt', 'xls', 'xlsx']
5353
VideoFormat: TypeAlias = Literal['mkv', 'mov', 'mp4', 'webm', 'flv', 'mpeg', 'mpg', 'wmv', 'three_gp']
5454

55+
# OpenTelemetry GenAI finish reasons used for gen_ai.response.finish_reasons
56+
# See mappings in provider implementations (e.g., OpenAI/Google) for how vendor reasons map here.
57+
OtelFinishReason: TypeAlias = Literal[
58+
'stop',
59+
'length',
60+
'content_filter',
61+
'tool_call',
62+
'error',
63+
]
64+
5565

5666
@dataclass(repr=False)
5767
class SystemPromptPart:
@@ -1032,6 +1042,13 @@ class ModelResponse:
10321042
] = None
10331043
"""request ID as specified by the model provider. This can be used to track the specific request to the model."""
10341044

1045+
finish_reason: OtelFinishReason | None = None
1046+
"""Reason the model finished generating the response, normalized to OTEL values.
1047+
1048+
Allowed values: 'stop', 'length', 'content_filter', 'tool_call', 'error'.
1049+
Used to populate gen_ai.response.finish_reasons in OpenTelemetry.
1050+
"""
1051+
10351052
@deprecated('`price` is deprecated, use `cost` instead')
10361053
def price(self) -> genai_types.PriceCalculation: # pragma: no cover
10371054
return self.cost()

pydantic_ai_slim/pydantic_ai/models/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
ModelRequest,
3333
ModelResponse,
3434
ModelResponseStreamEvent,
35+
OtelFinishReason,
3536
PartStartEvent,
3637
TextPart,
3738
ToolCallPart,
@@ -554,6 +555,8 @@ class StreamedResponse(ABC):
554555
model_request_parameters: ModelRequestParameters
555556

556557
final_result_event: FinalResultEvent | None = field(default=None, init=False)
558+
provider_response_id: str | None = field(default=None, init=False)
559+
finish_reason: OtelFinishReason | None = field(default=None, init=False)
557560

558561
_parts_manager: ModelResponsePartsManager = field(default_factory=ModelResponsePartsManager, init=False)
559562
_event_iterator: AsyncIterator[ModelResponseStreamEvent] | None = field(default=None, init=False)
@@ -609,6 +612,8 @@ def get(self) -> ModelResponse:
609612
timestamp=self.timestamp,
610613
usage=self.usage(),
611614
provider_name=self.provider_name,
615+
provider_response_id=self.provider_response_id,
616+
finish_reason=self.finish_reason,
612617
)
613618

614619
def usage(self) -> RequestUsage:

pydantic_ai_slim/pydantic_ai/models/anthropic.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ModelResponse,
2828
ModelResponsePart,
2929
ModelResponseStreamEvent,
30+
OtelFinishReason,
3031
RetryPromptPart,
3132
SystemPromptPart,
3233
TextPart,
@@ -42,6 +43,26 @@
4243
from ..tools import ToolDefinition
4344
from . import Model, ModelRequestParameters, StreamedResponse, check_allow_model_requests, download_item, get_user_agent
4445

46+
47+
def _map_anthropic_finish_reason(raw: str | None) -> OtelFinishReason | None:
48+
"""Map Anthropic stop_reason to OTEL finish reasons.
49+
50+
Known Anthropic values include: 'end_turn', 'max_tokens', 'stop_sequence', 'tool_use',
51+
as well as 'content_filtered' or 'safety' when content is filtered.
52+
"""
53+
if raw is None:
54+
return None
55+
if raw in ('end_turn', 'stop_sequence'):
56+
return 'stop'
57+
if raw == 'max_tokens':
58+
return 'length'
59+
if raw in ('content_filtered', 'safety'):
60+
return 'content_filter'
61+
if raw == 'tool_use':
62+
return 'other'
63+
return None
64+
65+
4566
try:
4667
from anthropic import NOT_GIVEN, APIStatusError, AsyncStream
4768
from anthropic.types.beta import (
@@ -326,12 +347,19 @@ def _process_response(self, response: BetaMessage) -> ModelResponse:
326347
)
327348
)
328349

350+
# Map finish_reason from Anthropic stop_reason
351+
raw_finish: str | None = response.stop_reason
352+
mapped_finish: OtelFinishReason | None = _map_anthropic_finish_reason(raw_finish)
353+
provider_details: dict[str, Any] | None = {'finish_reason': raw_finish} if raw_finish is not None else None
354+
329355
return ModelResponse(
330356
parts=items,
331357
usage=_map_usage(response),
332358
model_name=response.model,
333359
provider_response_id=response.id,
334360
provider_name=self._provider.name,
361+
finish_reason=mapped_finish,
362+
provider_details=provider_details,
335363
)
336364

337365
async def _process_streamed_response(
@@ -583,6 +611,13 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
583611
async for event in self._response:
584612
if isinstance(event, BetaRawMessageStartEvent):
585613
self._usage = _map_usage(event)
614+
# Capture provider response id from start event
615+
try:
616+
if self.provider_response_id is None:
617+
self.provider_response_id = getattr(event.message, 'id', None)
618+
except Exception:
619+
pass
620+
pass
586621

587622
elif isinstance(event, BetaRawContentBlockStartEvent):
588623
current_block = event.content_block
@@ -648,6 +683,13 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
648683
self._usage = _map_usage(event)
649684

650685
elif isinstance(event, BetaRawContentBlockStopEvent | BetaRawMessageStopEvent): # pragma: no branch
686+
# Capture mapped finish reason on message stop
687+
try:
688+
raw_reason = getattr(getattr(event, 'message', None), 'stop_reason', None)
689+
if self.finish_reason is None and raw_reason is not None:
690+
self.finish_reason = _map_anthropic_finish_reason(raw_reason)
691+
except Exception:
692+
pass
651693
current_block = None
652694

653695
@property

pydantic_ai_slim/pydantic_ai/models/google.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,42 @@ async def _build_content_and_config(
384384
)
385385
return contents, config
386386

387+
@staticmethod
388+
def _map_finish_reason_to_otel(raw: str | None) -> str | None:
389+
"""Map provider-specific finish reasons to OpenTelemetry gen_ai.response.finish_reasons values.
390+
391+
Only returns a value if it matches a known OTEL value; otherwise returns None.
392+
"""
393+
if raw is None:
394+
return None
395+
upper = raw.upper()
396+
# Known mappings for Google Gemini
397+
if upper == 'STOP':
398+
return 'stop'
399+
if upper in {'MAX_TOKENS', 'MAX_OUTPUT_TOKENS'}:
400+
return 'length'
401+
if upper in {'SAFETY', 'BLOCKLIST', 'PROHIBITED_CONTENT', 'SPII'}:
402+
return 'content_filter'
403+
# Unknown or provider-specific value — do not set
404+
return None
405+
406+
def _finish_reason_details(
407+
self, finish_reason: Any, vendor_id: str | None
408+
) -> tuple[dict[str, Any] | None, str | None]:
409+
"""Build provider_details and mapped OTEL finish_reason from a provider finish reason.
410+
411+
Returns a tuple of (provider_details, mapped_finish_reason).
412+
"""
413+
details: dict[str, Any] = {}
414+
mapped_finish_reason: str | None = None
415+
if finish_reason is not None:
416+
raw_finish_reason = getattr(finish_reason, 'value', str(finish_reason))
417+
details['finish_reason'] = raw_finish_reason
418+
mapped_finish_reason = self._map_finish_reason_to_otel(raw_finish_reason)
419+
if vendor_id:
420+
details['provider_response_id'] = vendor_id
421+
return (details or None), mapped_finish_reason
422+
387423
def _process_response(self, response: GenerateContentResponse) -> ModelResponse:
388424
if not response.candidates or len(response.candidates) != 1:
389425
raise UnexpectedModelBehavior('Expected exactly one candidate in Gemini response') # pragma: no cover
@@ -397,10 +433,7 @@ def _process_response(self, response: GenerateContentResponse) -> ModelResponse:
397433
) # pragma: no cover
398434
parts = candidate.content.parts or []
399435
vendor_id = response.response_id or None
400-
vendor_details: dict[str, Any] | None = None
401-
finish_reason = candidate.finish_reason
402-
if finish_reason: # pragma: no branch
403-
vendor_details = {'finish_reason': finish_reason.value}
436+
vendor_details, mapped_finish_reason = self._finish_reason_details(candidate.finish_reason, vendor_id)
404437
usage = _metadata_as_usage(response)
405438
return _process_response_from_parts(
406439
parts,
@@ -409,6 +442,7 @@ def _process_response(self, response: GenerateContentResponse) -> ModelResponse:
409442
usage,
410443
vendor_id=vendor_id,
411444
vendor_details=vendor_details,
445+
finish_reason=mapped_finish_reason,
412446
)
413447

414448
async def _process_streamed_response(
@@ -543,6 +577,11 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
543577

544578
assert chunk.candidates is not None
545579
candidate = chunk.candidates[0]
580+
581+
# Capture mapped finish_reason if provided by the candidate
582+
if self.finish_reason is None and candidate.finish_reason is not None:
583+
raw_fr = getattr(candidate.finish_reason, 'value', str(candidate.finish_reason))
584+
self.finish_reason = GoogleModel._map_finish_reason_to_otel(raw_fr)
546585
if candidate.content is None or candidate.content.parts is None:
547586
if candidate.finish_reason == 'STOP': # pragma: no cover
548587
# Normal completion - skip this chunk
@@ -625,6 +664,7 @@ def _process_response_from_parts(
625664
usage: usage.RequestUsage,
626665
vendor_id: str | None,
627666
vendor_details: dict[str, Any] | None = None,
667+
finish_reason: str | None = None,
628668
) -> ModelResponse:
629669
items: list[ModelResponsePart] = []
630670
for part in parts:
@@ -665,6 +705,7 @@ def _process_response_from_parts(
665705
provider_response_id=vendor_id,
666706
provider_details=vendor_details,
667707
provider_name=provider_name,
708+
finish_reason=finish_reason,
668709
)
669710

670711

pydantic_ai_slim/pydantic_ai/models/instrumented.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,11 @@ def handle_messages(self, input_messages: list[ModelMessage], response: ModelRes
271271
}
272272
),
273273
}
274+
# Also set finish reason and response ID as span attributes (v2 format)
275+
if response.provider_response_id is not None:
276+
attributes['gen_ai.response.id'] = response.provider_response_id
277+
if response.finish_reason is not None:
278+
attributes['gen_ai.response.finish_reasons'] = [response.finish_reason]
274279
span.set_attributes(attributes)
275280

276281
def system_instructions_attributes(self, instructions: str | None) -> dict[str, str]:

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
ModelResponse,
3131
ModelResponsePart,
3232
ModelResponseStreamEvent,
33+
OtelFinishReason,
3334
RetryPromptPart,
3435
SystemPromptPart,
3536
TextPart,
@@ -493,6 +494,12 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons
493494
],
494495
}
495496

497+
# Map finish_reason to OTEL and include raw in provider details
498+
mapped_finish_reason = _map_openai_chat_finish_reason(choice.finish_reason)
499+
if vendor_details is None:
500+
vendor_details = {}
501+
vendor_details['finish_reason'] = choice.finish_reason
502+
496503
if choice.message.content is not None:
497504
items.extend(split_content_into_text_and_thinking(choice.message.content, self.profile.thinking_tags))
498505
if choice.message.tool_calls is not None:
@@ -515,6 +522,7 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons
515522
provider_details=vendor_details,
516523
provider_response_id=response.id,
517524
provider_name=self._provider.name,
525+
finish_reason=mapped_finish_reason,
518526
)
519527

520528
async def _process_streamed_response(
@@ -718,6 +726,53 @@ async def _map_user_prompt(part: UserPromptPart) -> chat.ChatCompletionUserMessa
718726
return chat.ChatCompletionUserMessageParam(role='user', content=content)
719727

720728

729+
def _map_openai_responses_finish_reason(
730+
status: str | None, incomplete_reason: str | None
731+
) -> tuple[str | None, OtelFinishReason | None]:
732+
"""Map OpenAI Responses status/incomplete_details to (raw, OTEL-mapped) finish reasons.
733+
734+
Raw holds provider data for provider_details, while the mapped value is used for ModelResponse.finish_reason
735+
to comply with gen_ai.response.finish_reasons.
736+
"""
737+
if status is None:
738+
return None, None
739+
740+
# Incomplete: use the reason for more specific mapping
741+
if status == 'incomplete':
742+
raw = incomplete_reason or status
743+
if incomplete_reason == 'max_output_tokens':
744+
return raw, 'length'
745+
if incomplete_reason == 'content_filter':
746+
return raw, 'content_filter'
747+
if incomplete_reason == 'timeout':
748+
return raw, 'error'
749+
# Unknown reason for incomplete — do not set mapped value
750+
return raw, None
751+
752+
# Completed/cancelled/failed map to stop/error
753+
if status == 'completed':
754+
return status, 'stop'
755+
if status == 'cancelled':
756+
return status, 'error'
757+
if status == 'failed':
758+
return status, 'error'
759+
760+
# Unknown/other statuses -> keep raw, do not set mapped
761+
return status, None
762+
763+
764+
OPENAI_CHAT_FINISH_MAP: dict[str, OtelFinishReason] = {
765+
'stop': 'stop',
766+
'length': 'length',
767+
'content_filter': 'content_filter',
768+
'tool_calls': 'tool_call',
769+
}
770+
771+
772+
def _map_openai_chat_finish_reason(raw: str | None) -> OtelFinishReason | None:
773+
return OPENAI_CHAT_FINISH_MAP.get(raw) if raw else None
774+
775+
721776
@deprecated(
722777
'`OpenAIModel` was renamed to `OpenAIChatModel` to clearly distinguish it from `OpenAIResponsesModel` which '
723778
"uses OpenAI's newer Responses API. Use that unless you're using an OpenAI Chat Completions-compatible API, or "
@@ -823,13 +878,25 @@ def _process_response(self, response: responses.Response) -> ModelResponse:
823878
items.append(TextPart(content.text))
824879
elif item.type == 'function_call':
825880
items.append(ToolCallPart(item.name, item.arguments, tool_call_id=item.call_id))
881+
882+
# Map OpenAI Responses status/incomplete_details to OTEL-compliant finish_reasons
883+
details = response.incomplete_details
884+
incomplete_reason = details.reason if details else None
885+
raw_finish, mapped_finish = _map_openai_responses_finish_reason(response.status, incomplete_reason)
886+
887+
provider_details: dict[str, Any] | None = None
888+
if raw_finish is not None:
889+
provider_details = {'finish_reason': raw_finish}
890+
826891
return ModelResponse(
827892
parts=items,
828893
usage=_map_usage(response),
829894
model_name=response.model,
830895
provider_response_id=response.id,
831896
timestamp=timestamp,
832897
provider_name=self._provider.name,
898+
finish_reason=mapped_finish,
899+
provider_details=provider_details,
833900
)
834901

835902
async def _process_streamed_response(
@@ -1169,6 +1236,10 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
11691236
async for chunk in self._response:
11701237
self._usage += _map_usage(chunk)
11711238

1239+
# Capture the response ID from the chunk
1240+
if chunk.id and self.provider_response_id is None:
1241+
self.provider_response_id = chunk.id
1242+
11721243
try:
11731244
choice = chunk.choices[0]
11741245
except IndexError:
@@ -1177,6 +1248,9 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
11771248
# When using Azure OpenAI and an async content filter is enabled, the openai SDK can return None deltas.
11781249
if choice.delta is None: # pyright: ignore[reportUnnecessaryComparison]
11791250
continue
1251+
# Capture the finish_reason when it becomes available (mapped to OTEL)
1252+
if choice.finish_reason:
1253+
self.finish_reason = _map_openai_chat_finish_reason(choice.finish_reason)
11801254

11811255
# Handle the text part of the response
11821256
content = choice.delta.content
@@ -1236,6 +1310,14 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
12361310
# NOTE: You can inspect the builtin tools used checking the `ResponseCompletedEvent`.
12371311
if isinstance(chunk, responses.ResponseCompletedEvent):
12381312
self._usage += _map_usage(chunk.response)
1313+
# Capture id and mapped finish_reason from completed response
1314+
if chunk.response.id and self.provider_response_id is None:
1315+
self.provider_response_id = chunk.response.id
1316+
if self.finish_reason is None:
1317+
details = chunk.response.incomplete_details
1318+
incomplete_reason = details.reason if details else None
1319+
_, mapped = _map_openai_responses_finish_reason(chunk.response.status, incomplete_reason)
1320+
self.finish_reason = mapped
12391321

12401322
elif isinstance(chunk, responses.ResponseContentPartAddedEvent):
12411323
pass # there's nothing we need to do here
@@ -1244,7 +1326,9 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
12441326
pass # there's nothing we need to do here
12451327

12461328
elif isinstance(chunk, responses.ResponseCreatedEvent):
1247-
pass # there's nothing we need to do here
1329+
# Capture id from created response
1330+
if chunk.response.id and self.provider_response_id is None:
1331+
self.provider_response_id = chunk.response.id
12481332

12491333
elif isinstance(chunk, responses.ResponseFailedEvent): # pragma: no cover
12501334
self._usage += _map_usage(chunk.response)

0 commit comments

Comments
 (0)