Skip to content

Commit 35185ad

Browse files
committed
fix: #885
1 parent 66fa21a commit 35185ad

File tree

8 files changed

+2975
-2414
lines changed

8 files changed

+2975
-2414
lines changed

pydantic_ai_slim/pydantic_ai/messages.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,9 @@ class ModelResponse:
977977
provider_response_id: str | None = None
978978
"""request ID as specified by the model provider. This can be used to track the specific request to the model."""
979979

980+
finish_reason: str | None = None
981+
"""Reason the model finished generating the response. Used to populate gen_ai.response.finish_reasons in OpenTelemetry."""
982+
980983
def price(self) -> genai_types.PriceCalculation:
981984
"""Calculate the price of the usage.
982985

pydantic_ai_slim/pydantic_ai/models/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,8 @@ class StreamedResponse(ABC):
554554
model_request_parameters: ModelRequestParameters
555555

556556
final_result_event: FinalResultEvent | None = field(default=None, init=False)
557+
provider_response_id: str | None = field(default=None, init=False)
558+
finish_reason: str | None = field(default=None, init=False)
557559

558560
_parts_manager: ModelResponsePartsManager = field(default_factory=ModelResponsePartsManager, init=False)
559561
_event_iterator: AsyncIterator[ModelResponseStreamEvent] | None = field(default=None, init=False)
@@ -609,6 +611,8 @@ def get(self) -> ModelResponse:
609611
timestamp=self.timestamp,
610612
usage=self.usage(),
611613
provider_name=self.provider_name,
614+
provider_response_id=self.provider_response_id,
615+
finish_reason=self.finish_reason,
612616
)
613617

614618
def usage(self) -> RequestUsage:

pydantic_ai_slim/pydantic_ai/models/google.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from __future__ import annotations as _annotations
22

33
import base64
4+
import os
5+
import platform
46
from collections.abc import AsyncIterator, Awaitable
57
from contextlib import asynccontextmanager
68
from dataclasses import dataclass, field
7-
from datetime import datetime
9+
from datetime import datetime, timezone
10+
from pathlib import Path
811
from typing import Any, Literal, cast, overload
912
from uuid import uuid4
1013

@@ -374,6 +377,62 @@ async def _build_content_and_config(
374377
)
375378
return contents, config
376379

380+
@staticmethod
381+
def _execution_context() -> dict[str, Any]:
382+
"""Return execution context details for debugging/provider details."""
383+
try:
384+
pwd = os.getcwd()
385+
except Exception:
386+
pwd = None
387+
home = str(Path.home())
388+
389+
sys_platform = platform.system()
390+
if sys_platform == 'Darwin':
391+
os_platform = 'MacOS'
392+
elif sys_platform in ('Windows', 'Linux'):
393+
os_platform = sys_platform
394+
else:
395+
os_platform = sys_platform or None
396+
397+
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
398+
399+
shell_path = os.environ.get('SHELL') or os.environ.get('COMSPEC')
400+
shell_name = os.path.basename(shell_path) if shell_path else None
401+
shell_version = (
402+
os.environ.get('ZSH_VERSION')
403+
or os.environ.get('BASH_VERSION')
404+
or os.environ.get('FISH_VERSION')
405+
or os.environ.get('POWERSHELL_VERSION')
406+
or None
407+
)
408+
return {
409+
'execution_context': {
410+
'directory_state': {'pwd': pwd, 'home': home},
411+
'operating_system': {'platform': os_platform},
412+
'current_time': now,
413+
'shell': {'name': shell_name, 'version': shell_version},
414+
}
415+
}
416+
417+
@staticmethod
418+
def _map_finish_reason_to_otel(raw: str | None) -> str | None:
419+
"""Map provider-specific finish reasons to OpenTelemetry gen_ai.response.finish_reasons values.
420+
421+
Only returns a value if it matches a known OTEL value; otherwise returns None.
422+
"""
423+
if raw is None:
424+
return None
425+
upper = raw.upper()
426+
# Known mappings for Google Gemini
427+
if upper == 'STOP':
428+
return 'stop'
429+
if upper in {'MAX_TOKENS', 'MAX_OUTPUT_TOKENS'}:
430+
return 'length'
431+
if upper in {'SAFETY', 'BLOCKLIST', 'PROHIBITED_CONTENT', 'SPII'}:
432+
return 'content_filter'
433+
# Unknown or provider-specific value — do not set
434+
return None
435+
377436
def _process_response(self, response: GenerateContentResponse) -> ModelResponse:
378437
if not response.candidates or len(response.candidates) != 1:
379438
raise UnexpectedModelBehavior('Expected exactly one candidate in Gemini response') # pragma: no cover
@@ -387,10 +446,21 @@ def _process_response(self, response: GenerateContentResponse) -> ModelResponse:
387446
) # pragma: no cover
388447
parts = candidate.content.parts or []
389448
vendor_id = response.response_id or None
390-
vendor_details: dict[str, Any] | None = None
449+
details: dict[str, Any] = {}
391450
finish_reason = candidate.finish_reason
392-
if finish_reason: # pragma: no branch
393-
vendor_details = {'finish_reason': finish_reason.value}
451+
# Raw finish_reason from provider (enum -> string)
452+
raw_finish_reason = None
453+
if finish_reason is not None:
454+
raw_finish_reason = getattr(finish_reason, 'value', str(finish_reason))
455+
details['finish_reason'] = raw_finish_reason
456+
# OTEL-mapped finish_reason
457+
mapped_finish_reason = self._map_finish_reason_to_otel(raw_finish_reason) if raw_finish_reason else None
458+
if mapped_finish_reason is not None:
459+
details['final_reason'] = mapped_finish_reason
460+
if vendor_id:
461+
details['provider_response_id'] = vendor_id
462+
details.update(self._execution_context())
463+
vendor_details: dict[str, Any] | None = details or None
394464
usage = _metadata_as_usage(response)
395465
return _process_response_from_parts(
396466
parts,
@@ -399,6 +469,7 @@ def _process_response(self, response: GenerateContentResponse) -> ModelResponse:
399469
usage,
400470
vendor_id=vendor_id,
401471
vendor_details=vendor_details,
472+
finish_reason=mapped_finish_reason,
402473
)
403474

404475
async def _process_streamed_response(
@@ -615,6 +686,7 @@ def _process_response_from_parts(
615686
usage: usage.RequestUsage,
616687
vendor_id: str | None,
617688
vendor_details: dict[str, Any] | None = None,
689+
finish_reason: str | None = None,
618690
) -> ModelResponse:
619691
items: list[ModelResponsePart] = []
620692
for part in parts:
@@ -655,6 +727,7 @@ def _process_response_from_parts(
655727
provider_response_id=vendor_id,
656728
provider_details=vendor_details,
657729
provider_name=provider_name,
730+
finish_reason=finish_reason,
658731
)
659732

660733

pydantic_ai_slim/pydantic_ai/models/instrumented.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -419,13 +419,29 @@ def _record_metrics():
419419
if not span.is_recording():
420420
return
421421

422-
self.instrumentation_settings.handle_messages(messages, response, system, span)
423-
span.set_attributes(
424-
{
425-
**response.usage.opentelemetry_attributes(),
426-
'gen_ai.response.model': response_model,
422+
events = self.instrumentation_settings.messages_to_otel_events(messages)
423+
for event in self.instrumentation_settings.messages_to_otel_events([response]):
424+
choice_body: dict[str, Any] = {
425+
'index': 0,
426+
'message': event.body,
427427
}
428-
)
428+
if response.finish_reason is not None:
429+
choice_body['finish_reason'] = response.finish_reason
430+
events.append(
431+
Event(
432+
'gen_ai.choice',
433+
body=choice_body,
434+
)
435+
)
436+
response_attributes = {
437+
**response.usage.opentelemetry_attributes(),
438+
'gen_ai.response.model': response_model,
439+
}
440+
if response.provider_response_id is not None:
441+
response_attributes['gen_ai.response.id'] = response.provider_response_id
442+
if response.finish_reason is not None:
443+
response_attributes['gen_ai.response.finish_reasons'] = [response.finish_reason]
444+
span.set_attributes(response_attributes)
429445
span.update_name(f'{operation} {request_model}')
430446

431447
yield finish

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons
512512
provider_details=vendor_details,
513513
provider_response_id=response.id,
514514
provider_name=self._provider.name,
515+
finish_reason=choice.finish_reason,
515516
)
516517

517518
async def _process_streamed_response(
@@ -603,6 +604,39 @@ def _map_tool_call(t: ToolCallPart) -> ChatCompletionMessageFunctionToolCallPara
603604
function={'name': t.tool_name, 'arguments': t.args_as_json_str()},
604605
)
605606

607+
608+
def _map_openai_responses_finish(status: str | None, incomplete_reason: str | None) -> tuple[str | None, str | None]:
609+
"""Map OpenAI Responses status/incomplete_details to (raw, OTEL-mapped) finish reasons.
610+
611+
Raw holds provider data for provider_details, while the mapped value is used for ModelResponse.finish_reason
612+
to comply with gen_ai.response.finish_reasons.
613+
"""
614+
if status is None:
615+
return None, None
616+
617+
# Incomplete: use the reason for more specific mapping
618+
if status == 'incomplete':
619+
raw = incomplete_reason or status
620+
if incomplete_reason == 'max_output_tokens':
621+
return raw, 'length'
622+
if incomplete_reason == 'content_filter':
623+
return raw, 'content_filter'
624+
if incomplete_reason == 'timeout':
625+
return raw, 'timeout'
626+
# Unknown reason for incomplete
627+
return raw, 'other'
628+
629+
# Completed/cancelled/failed map to stop/cancelled/error
630+
if status == 'completed':
631+
return status, 'stop'
632+
if status == 'cancelled':
633+
return status, 'cancelled'
634+
if status == 'failed':
635+
return status, 'error'
636+
637+
# Unknown/other statuses -> keep raw, do not set mapped
638+
return status, None
639+
606640
def _map_json_schema(self, o: OutputObjectDefinition) -> chat.completion_create_params.ResponseFormat:
607641
response_format_param: chat.completion_create_params.ResponseFormatJSONSchema = { # pyright: ignore[reportPrivateImportUsage]
608642
'type': 'json_schema',
@@ -820,13 +854,26 @@ def _process_response(self, response: responses.Response) -> ModelResponse:
820854
items.append(TextPart(content.text))
821855
elif item.type == 'function_call':
822856
items.append(ToolCallPart(item.name, item.arguments, tool_call_id=item.call_id))
857+
858+
# Map OpenAI Responses status/incomplete_details to OTEL-compliant finish_reasons
859+
incomplete_reason = getattr(getattr(response, 'incomplete_details', None), 'reason', None)
860+
raw_finish, mapped_finish = _map_openai_responses_finish(response.status, incomplete_reason)
861+
862+
provider_details: dict[str, Any] | None = None
863+
if raw_finish is not None or mapped_finish is not None:
864+
provider_details = {'finish_reason': raw_finish}
865+
if mapped_finish is not None:
866+
provider_details['final_reason'] = mapped_finish
867+
823868
return ModelResponse(
824869
parts=items,
825870
usage=_map_usage(response),
826871
model_name=response.model,
827872
provider_response_id=response.id,
828873
timestamp=timestamp,
829874
provider_name=self._provider.name,
875+
finish_reason=mapped_finish,
876+
provider_details=provider_details,
830877
)
831878

832879
async def _process_streamed_response(
@@ -1166,11 +1213,19 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
11661213
async for chunk in self._response:
11671214
self._usage += _map_usage(chunk)
11681215

1216+
# Capture the response ID from the chunk
1217+
if chunk.id and self.provider_response_id is None:
1218+
self.provider_response_id = chunk.id
1219+
11691220
try:
11701221
choice = chunk.choices[0]
11711222
except IndexError:
11721223
continue
11731224

1225+
# Capture the finish_reason when it becomes available
1226+
if choice.finish_reason:
1227+
self.finish_reason = choice.finish_reason
1228+
11741229
# Handle the text part of the response
11751230
content = choice.delta.content
11761231
if content is not None:
@@ -1229,6 +1284,13 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
12291284
# NOTE: You can inspect the builtin tools used checking the `ResponseCompletedEvent`.
12301285
if isinstance(chunk, responses.ResponseCompletedEvent):
12311286
self._usage += _map_usage(chunk.response)
1287+
# Capture id and mapped finish_reason from completed response
1288+
if chunk.response.id and self.provider_response_id is None:
1289+
self.provider_response_id = chunk.response.id
1290+
if self.finish_reason is None:
1291+
incomplete_reason = getattr(getattr(chunk.response, 'incomplete_details', None), 'reason', None)
1292+
_raw, mapped = _map_openai_responses_finish(chunk.response.status, incomplete_reason)
1293+
self.finish_reason = mapped
12321294

12331295
elif isinstance(chunk, responses.ResponseContentPartAddedEvent):
12341296
pass # there's nothing we need to do here
@@ -1237,7 +1299,9 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
12371299
pass # there's nothing we need to do here
12381300

12391301
elif isinstance(chunk, responses.ResponseCreatedEvent):
1240-
pass # there's nothing we need to do here
1302+
# Capture id from created response
1303+
if chunk.response.id and self.provider_response_id is None:
1304+
self.provider_response_id = chunk.response.id
12411305

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

tests/models/mock_openai.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,24 @@ def get_mock_chat_completion_kwargs(async_open_ai: AsyncOpenAI) -> list[dict[str
7777

7878

7979
def completion_message(
80-
message: ChatCompletionMessage, *, usage: CompletionUsage | None = None, logprobs: ChoiceLogprobs | None = None
80+
message: ChatCompletionMessage,
81+
*,
82+
usage: CompletionUsage | None = None,
83+
logprobs: ChoiceLogprobs | None = None,
84+
provider_response_id: str | None = None,
85+
finish_reason: str | None = None,
86+
model: str | None = None,
87+
created: int | None = None,
8188
) -> chat.ChatCompletion:
82-
choices = [Choice(finish_reason='stop', index=0, message=message)]
89+
fr = finish_reason or 'stop'
90+
choices = [Choice(finish_reason=fr, index=0, message=message)]
8391
if logprobs:
84-
choices = [Choice(finish_reason='stop', index=0, message=message, logprobs=logprobs)]
92+
choices = [Choice(finish_reason=fr, index=0, message=message, logprobs=logprobs)]
8593
return chat.ChatCompletion(
86-
id='123',
94+
id=provider_response_id or '123',
8795
choices=choices,
88-
created=1704067200, # 2024-01-01
89-
model='gpt-4o-123',
96+
created=created or 1704067200, # 2024-01-01
97+
model=model or 'gpt-4o-123',
9098
object='chat.completion',
9199
usage=usage,
92100
)

0 commit comments

Comments
 (0)