Skip to content

Commit a24c087

Browse files
committed
fix: #886
1 parent 6207ac6 commit a24c087

File tree

6 files changed

+3047
-2697
lines changed

6 files changed

+3047
-2697
lines changed

pydantic_ai_slim/pydantic_ai/messages.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,12 @@ class ModelResponse:
792792
vendor_id: str | None = None
793793
"""Vendor ID as specified by the model provider. This can be used to track the specific request to the model."""
794794

795+
id: str | None = None
796+
"""Response ID as specified by the model provider. Used to populate gen_ai.response.id in OpenTelemetry."""
797+
798+
finish_reason: str | None = None
799+
"""Reason the model finished generating the response. Used to populate gen_ai.response.finish_reasons in OpenTelemetry."""
800+
795801
def otel_events(self, settings: InstrumentationSettings) -> list[Event]:
796802
"""Return OpenTelemetry events for the response."""
797803
result: list[Event] = []

pydantic_ai_slim/pydantic_ai/models/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,8 @@ class StreamedResponse(ABC):
503503
_parts_manager: ModelResponsePartsManager = field(default_factory=ModelResponsePartsManager, init=False)
504504
_event_iterator: AsyncIterator[ModelResponseStreamEvent] | None = field(default=None, init=False)
505505
_usage: Usage = field(default_factory=Usage, init=False)
506+
_id: str | None = field(default=None, init=False)
507+
_finish_reason: str | None = field(default=None, init=False)
506508

507509
def __aiter__(self) -> AsyncIterator[ModelResponseStreamEvent]:
508510
"""Stream the response as an async iterable of [`ModelResponseStreamEvent`][pydantic_ai.messages.ModelResponseStreamEvent]s."""
@@ -530,6 +532,8 @@ def get(self) -> ModelResponse:
530532
model_name=self.model_name,
531533
timestamp=self.timestamp,
532534
usage=self.usage(),
535+
id=self._id,
536+
finish_reason=self._finish_reason,
533537
)
534538

535539
def usage(self) -> Usage:

pydantic_ai_slim/pydantic_ai/models/instrumented.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -301,22 +301,27 @@ def _record_metrics():
301301

302302
events = self.instrumentation_settings.messages_to_otel_events(messages)
303303
for event in self.instrumentation_settings.messages_to_otel_events([response]):
304+
choice_body: dict[str, Any] = {
305+
'index': 0,
306+
'message': event.body,
307+
}
308+
if response.finish_reason is not None:
309+
choice_body['finish_reason'] = response.finish_reason
304310
events.append(
305311
Event(
306312
'gen_ai.choice',
307-
body={
308-
# TODO finish_reason
309-
'index': 0,
310-
'message': event.body,
311-
},
313+
body=choice_body,
312314
)
313315
)
314-
span.set_attributes(
315-
{
316-
**response.usage.opentelemetry_attributes(),
317-
'gen_ai.response.model': response_model,
318-
}
319-
)
316+
response_attributes = {
317+
**response.usage.opentelemetry_attributes(),
318+
'gen_ai.response.model': response_model,
319+
}
320+
if response.id is not None:
321+
response_attributes['gen_ai.response.id'] = response.id
322+
if response.finish_reason is not None:
323+
response_attributes['gen_ai.response.finish_reasons'] = [response.finish_reason]
324+
span.set_attributes(response_attributes)
320325
span.update_name(f'{operation} {request_model}')
321326
for event in events:
322327
event.attributes = {

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,8 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons
420420
timestamp=timestamp,
421421
vendor_details=vendor_details,
422422
vendor_id=response.id,
423+
id=response.id,
424+
finish_reason=choice.finish_reason,
423425
)
424426

425427
async def _process_streamed_response(self, response: AsyncStream[ChatCompletionChunk]) -> OpenAIStreamedResponse:
@@ -708,6 +710,8 @@ def _process_response(self, response: responses.Response) -> ModelResponse:
708710
model_name=response.model,
709711
vendor_id=response.id,
710712
timestamp=timestamp,
713+
id=response.id,
714+
finish_reason=response.status,
711715
)
712716

713717
async def _process_streamed_response(
@@ -1015,11 +1019,19 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
10151019
async for chunk in self._response:
10161020
self._usage += _map_usage(chunk)
10171021

1022+
# Capture the response ID from the chunk
1023+
if chunk.id and self._id is None:
1024+
self._id = chunk.id
1025+
10181026
try:
10191027
choice = chunk.choices[0]
10201028
except IndexError:
10211029
continue
10221030

1031+
# Capture the finish_reason when it becomes available
1032+
if choice.finish_reason and self._finish_reason is None:
1033+
self._finish_reason = choice.finish_reason
1034+
10231035
# Handle the text part of the response
10241036
content = choice.delta.content
10251037
if content:
@@ -1068,6 +1080,11 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
10681080
async for chunk in self._response:
10691081
if isinstance(chunk, responses.ResponseCompletedEvent):
10701082
self._usage += _map_usage(chunk.response)
1083+
# Capture id and finish_reason from completed response
1084+
if chunk.response.id and self._id is None:
1085+
self._id = chunk.response.id
1086+
if chunk.response.status and self._finish_reason is None:
1087+
self._finish_reason = chunk.response.status
10711088

10721089
elif isinstance(chunk, responses.ResponseContentPartAddedEvent):
10731090
pass # there's nothing we need to do here
@@ -1076,7 +1093,9 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
10761093
pass # there's nothing we need to do here
10771094

10781095
elif isinstance(chunk, responses.ResponseCreatedEvent):
1079-
pass # there's nothing we need to do here
1096+
# Capture id from created response
1097+
if chunk.response.id and self._id is None:
1098+
self._id = chunk.response.id
10801099

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

tests/models/test_openai.py

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,18 @@ def get_mock_chat_completion_kwargs(async_open_ai: AsyncOpenAI) -> list[dict[str
147147

148148

149149
def completion_message(
150-
message: ChatCompletionMessage, *, usage: CompletionUsage | None = None, logprobs: ChoiceLogprobs | None = None
150+
message: ChatCompletionMessage,
151+
*,
152+
usage: CompletionUsage | None = None,
153+
logprobs: ChoiceLogprobs | None = None,
154+
response_id: str = '123',
155+
finish_reason: str = 'stop'
151156
) -> chat.ChatCompletion:
152-
choices = [Choice(finish_reason='stop', index=0, message=message)]
157+
choices = [Choice(finish_reason=finish_reason, index=0, message=message)]
153158
if logprobs:
154-
choices = [Choice(finish_reason='stop', index=0, message=message, logprobs=logprobs)]
159+
choices = [Choice(finish_reason=finish_reason, index=0, message=message, logprobs=logprobs)]
155160
return chat.ChatCompletion(
156-
id='123',
161+
id=response_id,
157162
choices=choices,
158163
created=1704067200, # 2024-01-01
159164
model='gpt-4o-123',
@@ -189,6 +194,8 @@ async def test_request_simple_success(allow_model_requests: None):
189194
model_name='gpt-4o-123',
190195
timestamp=datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
191196
vendor_id='123',
197+
id='123',
198+
finish_reason='stop',
192199
),
193200
ModelRequest(parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))]),
194201
ModelResponse(
@@ -197,6 +204,8 @@ async def test_request_simple_success(allow_model_requests: None):
197204
model_name='gpt-4o-123',
198205
timestamp=datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
199206
vendor_id='123',
207+
id='123',
208+
finish_reason='stop',
200209
),
201210
]
202211
)
@@ -234,6 +243,36 @@ async def test_request_simple_usage(allow_model_requests: None):
234243
assert result.usage() == snapshot(Usage(requests=1, request_tokens=2, response_tokens=1, total_tokens=3))
235244

236245

246+
async def test_id_and_finish_reason_fields(allow_model_requests: None):
247+
"""Test that id and finish_reason fields are properly populated in ModelResponse."""
248+
# Test with different finish reasons
249+
test_cases = [
250+
('stop', 'response-id-1'),
251+
('length', 'response-id-2'),
252+
('tool_calls', 'response-id-3'),
253+
]
254+
255+
for finish_reason, response_id in test_cases:
256+
c = completion_message(
257+
ChatCompletionMessage(content='test response', role='assistant'),
258+
response_id=response_id,
259+
finish_reason=finish_reason,
260+
)
261+
mock_client = MockOpenAI.create_mock(c)
262+
m = OpenAIModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
263+
agent = Agent(m)
264+
265+
result = await agent.run('test')
266+
assert result.output == 'test response'
267+
268+
# Check that the ModelResponse contains the correct id and finish_reason
269+
messages = result.all_messages()
270+
model_response = messages[1] # Second message should be the model response
271+
assert isinstance(model_response, ModelResponse)
272+
assert model_response.id == response_id
273+
assert model_response.finish_reason == finish_reason
274+
275+
237276
async def test_request_structured_response(allow_model_requests: None):
238277
c = completion_message(
239278
ChatCompletionMessage(
@@ -420,9 +459,9 @@ async def get_location(loc_name: str) -> str:
420459
FinishReason = Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call']
421460

422461

423-
def chunk(delta: list[ChoiceDelta], finish_reason: FinishReason | None = None) -> chat.ChatCompletionChunk:
462+
def chunk(delta: list[ChoiceDelta], finish_reason: FinishReason | None = None, chunk_id: str = 'x') -> chat.ChatCompletionChunk:
424463
return chat.ChatCompletionChunk(
425-
id='x',
464+
id=chunk_id,
426465
choices=[
427466
ChunkChoice(index=index, delta=delta, finish_reason=finish_reason) for index, delta in enumerate(delta)
428467
],
@@ -433,8 +472,8 @@ def chunk(delta: list[ChoiceDelta], finish_reason: FinishReason | None = None) -
433472
)
434473

435474

436-
def text_chunk(text: str, finish_reason: FinishReason | None = None) -> chat.ChatCompletionChunk:
437-
return chunk([ChoiceDelta(content=text, role='assistant')], finish_reason=finish_reason)
475+
def text_chunk(text: str, finish_reason: FinishReason | None = None, chunk_id: str = 'x') -> chat.ChatCompletionChunk:
476+
return chunk([ChoiceDelta(content=text, role='assistant')], finish_reason=finish_reason, chunk_id=chunk_id)
438477

439478

440479
async def test_stream_text(allow_model_requests: None):
@@ -550,6 +589,55 @@ async def test_stream_structured_finish_reason(allow_model_requests: None):
550589
assert result.is_complete
551590

552591

592+
async def test_stream_id_and_finish_reason_fields(allow_model_requests: None):
593+
"""Test that streaming responses properly track id and finish_reason fields."""
594+
# Test streaming text response
595+
stream = [
596+
text_chunk('hello ', chunk_id='stream-response-123'),
597+
text_chunk('world', chunk_id='stream-response-123'),
598+
text_chunk('!', finish_reason='stop', chunk_id='stream-response-123'),
599+
]
600+
mock_client = MockOpenAI.create_mock_stream(stream)
601+
m = OpenAIModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
602+
agent = Agent(m)
603+
604+
async with agent.run_stream('test') as result:
605+
assert not result.is_complete
606+
text_chunks = [c async for c in result.stream_text(debounce_by=None)]
607+
assert text_chunks == ['hello ', 'hello world', 'hello world!']
608+
assert result.is_complete
609+
610+
# Get the final messages and check the ModelResponse
611+
messages = result.all_messages()
612+
model_response = messages[1] # Second message should be the model response
613+
assert isinstance(model_response, ModelResponse)
614+
assert model_response.id == 'stream-response-123'
615+
assert model_response.finish_reason == 'stop'
616+
617+
# Test streaming with structured output and different finish reason
618+
stream = [
619+
struc_chunk('final_result', None),
620+
chunk([ChoiceDelta(tool_calls=[ChoiceDeltaToolCall(index=0, function=ChoiceDeltaToolCallFunction(name=None, arguments='{"first": "Test"'))])], chunk_id='struct-response-456'),
621+
chunk([ChoiceDelta(tool_calls=[ChoiceDeltaToolCall(index=0, function=ChoiceDeltaToolCallFunction(name=None, arguments='}'))])], finish_reason='length', chunk_id='struct-response-456'),
622+
]
623+
mock_client = MockOpenAI.create_mock_stream(stream)
624+
m = OpenAIModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
625+
agent = Agent(m, output_type=MyTypedDict)
626+
627+
async with agent.run_stream('test') as result:
628+
assert not result.is_complete
629+
structured_chunks = [dict(c) async for c in result.stream(debounce_by=None)]
630+
assert structured_chunks == [{'first': 'Test'}, {'first': 'Test'}]
631+
assert result.is_complete
632+
633+
# Get the final messages and check the ModelResponse
634+
messages = result.all_messages()
635+
model_response = messages[1] # Second message should be the model response
636+
assert isinstance(model_response, ModelResponse)
637+
assert model_response.id == 'struct-response-456'
638+
assert model_response.finish_reason == 'length'
639+
640+
553641
async def test_stream_native_output(allow_model_requests: None):
554642
stream = [
555643
chunk([]),

0 commit comments

Comments
 (0)