30
30
ModelResponse ,
31
31
ModelResponsePart ,
32
32
ModelResponseStreamEvent ,
33
+ OtelFinishReason ,
33
34
RetryPromptPart ,
34
35
SystemPromptPart ,
35
36
TextPart ,
@@ -493,6 +494,12 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons
493
494
],
494
495
}
495
496
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
+
496
503
if choice .message .content is not None :
497
504
items .extend (split_content_into_text_and_thinking (choice .message .content , self .profile .thinking_tags ))
498
505
if choice .message .tool_calls is not None :
@@ -515,6 +522,7 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons
515
522
provider_details = vendor_details ,
516
523
provider_response_id = response .id ,
517
524
provider_name = self ._provider .name ,
525
+ finish_reason = mapped_finish_reason ,
518
526
)
519
527
520
528
async def _process_streamed_response (
@@ -718,6 +726,53 @@ async def _map_user_prompt(part: UserPromptPart) -> chat.ChatCompletionUserMessa
718
726
return chat .ChatCompletionUserMessageParam (role = 'user' , content = content )
719
727
720
728
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
+
721
776
@deprecated (
722
777
'`OpenAIModel` was renamed to `OpenAIChatModel` to clearly distinguish it from `OpenAIResponsesModel` which '
723
778
"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:
823
878
items .append (TextPart (content .text ))
824
879
elif item .type == 'function_call' :
825
880
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
+
826
891
return ModelResponse (
827
892
parts = items ,
828
893
usage = _map_usage (response ),
829
894
model_name = response .model ,
830
895
provider_response_id = response .id ,
831
896
timestamp = timestamp ,
832
897
provider_name = self ._provider .name ,
898
+ finish_reason = mapped_finish ,
899
+ provider_details = provider_details ,
833
900
)
834
901
835
902
async def _process_streamed_response (
@@ -1169,6 +1236,10 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
1169
1236
async for chunk in self ._response :
1170
1237
self ._usage += _map_usage (chunk )
1171
1238
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
+
1172
1243
try :
1173
1244
choice = chunk .choices [0 ]
1174
1245
except IndexError :
@@ -1177,6 +1248,9 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
1177
1248
# When using Azure OpenAI and an async content filter is enabled, the openai SDK can return None deltas.
1178
1249
if choice .delta is None : # pyright: ignore[reportUnnecessaryComparison]
1179
1250
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 )
1180
1254
1181
1255
# Handle the text part of the response
1182
1256
content = choice .delta .content
@@ -1236,6 +1310,14 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
1236
1310
# NOTE: You can inspect the builtin tools used checking the `ResponseCompletedEvent`.
1237
1311
if isinstance (chunk , responses .ResponseCompletedEvent ):
1238
1312
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
1239
1321
1240
1322
elif isinstance (chunk , responses .ResponseContentPartAddedEvent ):
1241
1323
pass # there's nothing we need to do here
@@ -1244,7 +1326,9 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
1244
1326
pass # there's nothing we need to do here
1245
1327
1246
1328
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
1248
1332
1249
1333
elif isinstance (chunk , responses .ResponseFailedEvent ): # pragma: no cover
1250
1334
self ._usage += _map_usage (chunk .response )
0 commit comments