3
3
import dataclasses
4
4
import json
5
5
import time
6
- from collections .abc import AsyncIterator , Iterable
6
+ from collections .abc import AsyncIterator
7
7
from dataclasses import dataclass , field
8
8
from typing import TYPE_CHECKING , Any , Literal , cast , overload
9
9
10
- from openai import NOT_GIVEN , AsyncOpenAI , AsyncStream , NotGiven
10
+ from openai import NOT_GIVEN , AsyncOpenAI , AsyncStream
11
11
from openai .types import ChatModel
12
- from openai .types .chat import (
13
- ChatCompletion ,
14
- ChatCompletionAssistantMessageParam ,
15
- ChatCompletionChunk ,
16
- ChatCompletionContentPartImageParam ,
17
- ChatCompletionContentPartParam ,
18
- ChatCompletionContentPartTextParam ,
19
- ChatCompletionDeveloperMessageParam ,
20
- ChatCompletionMessage ,
21
- ChatCompletionMessageParam ,
22
- ChatCompletionMessageToolCallParam ,
23
- ChatCompletionSystemMessageParam ,
24
- ChatCompletionToolChoiceOptionParam ,
25
- ChatCompletionToolMessageParam ,
26
- ChatCompletionUserMessageParam ,
27
- )
28
- from openai .types .chat .chat_completion_tool_param import ChatCompletionToolParam
29
- from openai .types .chat .completion_create_params import ResponseFormat
12
+ from openai .types .chat import ChatCompletion , ChatCompletionChunk
30
13
from openai .types .completion_usage import CompletionUsage
31
14
from openai .types .responses import (
32
- EasyInputMessageParam ,
33
15
Response ,
34
16
ResponseCompletedEvent ,
35
17
ResponseContentPartAddedEvent ,
36
18
ResponseContentPartDoneEvent ,
37
19
ResponseCreatedEvent ,
38
- ResponseFileSearchToolCallParam ,
39
20
ResponseFunctionCallArgumentsDeltaEvent ,
40
21
ResponseFunctionToolCall ,
41
- ResponseFunctionToolCallParam ,
42
- ResponseInputContentParam ,
43
- ResponseInputImageParam ,
44
- ResponseInputTextParam ,
45
22
ResponseOutputItem ,
46
23
ResponseOutputItemAddedEvent ,
47
24
ResponseOutputItemDoneEvent ,
48
25
ResponseOutputMessage ,
49
- ResponseOutputMessageParam ,
50
26
ResponseOutputRefusal ,
51
27
ResponseOutputText ,
52
28
ResponseRefusalDeltaEvent ,
53
29
ResponseTextDeltaEvent ,
54
30
ResponseUsage ,
55
31
)
56
- from openai .types .responses .response_input_param import FunctionCallOutput , ItemReference , Message
57
32
from openai .types .responses .response_usage import InputTokensDetails , OutputTokensDetails
58
33
59
34
from .. import _debug
60
35
from ..agent_output import AgentOutputSchema
61
- from ..exceptions import AgentsException , UserError
62
36
from ..handoffs import Handoff
63
- from ..items import ModelResponse , TResponseInputItem , TResponseOutputItem , TResponseStreamEvent
37
+ from ..items import ModelResponse , TResponseInputItem , TResponseStreamEvent
64
38
from ..logger import logger
65
- from ..tool import FunctionTool , Tool
39
+ from ..tool import Tool
66
40
from ..tracing import generation_span
67
41
from ..tracing .span_data import GenerationSpanData
68
42
from ..tracing .spans import Span
69
43
from ..usage import Usage
70
44
from ..version import __version__
45
+ from .chatcmpl_converter import Converter
71
46
from .fake_id import FAKE_RESPONSES_ID
72
47
from .interface import Model , ModelTracing
73
48
@@ -152,7 +127,7 @@ async def get_response(
152
127
"output_tokens" : usage .output_tokens ,
153
128
}
154
129
155
- items = _Converter .message_to_output_items (response .choices [0 ].message )
130
+ items = Converter .message_to_output_items (response .choices [0 ].message )
156
131
157
132
return ModelResponse (
158
133
output = items ,
@@ -486,7 +461,7 @@ async def _fetch_response(
486
461
tracing : ModelTracing ,
487
462
stream : bool = False ,
488
463
) -> ChatCompletion | tuple [Response , AsyncStream [ChatCompletionChunk ]]:
489
- converted_messages = _Converter .items_to_messages (input )
464
+ converted_messages = Converter .items_to_messages (input )
490
465
491
466
if system_instructions :
492
467
converted_messages .insert (
@@ -506,13 +481,13 @@ async def _fetch_response(
506
481
if model_settings .parallel_tool_calls is False
507
482
else NOT_GIVEN
508
483
)
509
- tool_choice = _Converter .convert_tool_choice (model_settings .tool_choice )
510
- response_format = _Converter .convert_response_format (output_schema )
484
+ tool_choice = Converter .convert_tool_choice (model_settings .tool_choice )
485
+ response_format = Converter .convert_response_format (output_schema )
511
486
512
- converted_tools = [ToolConverter . to_openai (tool ) for tool in tools ] if tools else []
487
+ converted_tools = [Converter . tool_to_openai (tool ) for tool in tools ] if tools else []
513
488
514
489
for handoff in handoffs :
515
- converted_tools .append (ToolConverter .convert_handoff_tool (handoff ))
490
+ converted_tools .append (Converter .convert_handoff_tool (handoff ))
516
491
517
492
if _debug .DONT_LOG_MODEL_DATA :
518
493
logger .debug ("Calling LLM" )
@@ -526,9 +501,9 @@ async def _fetch_response(
526
501
)
527
502
528
503
reasoning_effort = model_settings .reasoning .effort if model_settings .reasoning else None
529
- store = _Converter .get_store_param (self ._get_client (), model_settings )
504
+ store = _Helpers .get_store_param (self ._get_client (), model_settings )
530
505
531
- stream_options = _Converter .get_stream_options_param (
506
+ stream_options = _Helpers .get_stream_options_param (
532
507
self ._get_client (), model_settings , stream = stream
533
508
)
534
509
@@ -580,7 +555,7 @@ def _get_client(self) -> AsyncOpenAI:
580
555
return self ._client
581
556
582
557
583
- class _Converter :
558
+ class _Helpers :
584
559
@classmethod
585
560
def is_openai (cls , client : AsyncOpenAI ):
586
561
return str (client .base_url ).startswith ("https://api.openai.com" )
@@ -606,425 +581,3 @@ def get_stream_options_param(
606
581
)
607
582
stream_options = {"include_usage" : include_usage } if include_usage is not None else None
608
583
return stream_options
609
-
610
- @classmethod
611
- def convert_tool_choice (
612
- cls , tool_choice : Literal ["auto" , "required" , "none" ] | str | None
613
- ) -> ChatCompletionToolChoiceOptionParam | NotGiven :
614
- if tool_choice is None :
615
- return NOT_GIVEN
616
- elif tool_choice == "auto" :
617
- return "auto"
618
- elif tool_choice == "required" :
619
- return "required"
620
- elif tool_choice == "none" :
621
- return "none"
622
- else :
623
- return {
624
- "type" : "function" ,
625
- "function" : {
626
- "name" : tool_choice ,
627
- },
628
- }
629
-
630
- @classmethod
631
- def convert_response_format (
632
- cls , final_output_schema : AgentOutputSchema | None
633
- ) -> ResponseFormat | NotGiven :
634
- if not final_output_schema or final_output_schema .is_plain_text ():
635
- return NOT_GIVEN
636
-
637
- return {
638
- "type" : "json_schema" ,
639
- "json_schema" : {
640
- "name" : "final_output" ,
641
- "strict" : final_output_schema .strict_json_schema ,
642
- "schema" : final_output_schema .json_schema (),
643
- },
644
- }
645
-
646
- @classmethod
647
- def message_to_output_items (cls , message : ChatCompletionMessage ) -> list [TResponseOutputItem ]:
648
- items : list [TResponseOutputItem ] = []
649
-
650
- message_item = ResponseOutputMessage (
651
- id = FAKE_RESPONSES_ID ,
652
- content = [],
653
- role = "assistant" ,
654
- type = "message" ,
655
- status = "completed" ,
656
- )
657
- if message .content :
658
- message_item .content .append (
659
- ResponseOutputText (text = message .content , type = "output_text" , annotations = [])
660
- )
661
- if message .refusal :
662
- message_item .content .append (
663
- ResponseOutputRefusal (refusal = message .refusal , type = "refusal" )
664
- )
665
- if message .audio :
666
- raise AgentsException ("Audio is not currently supported" )
667
-
668
- if message_item .content :
669
- items .append (message_item )
670
-
671
- if message .tool_calls :
672
- for tool_call in message .tool_calls :
673
- items .append (
674
- ResponseFunctionToolCall (
675
- id = FAKE_RESPONSES_ID ,
676
- call_id = tool_call .id ,
677
- arguments = tool_call .function .arguments ,
678
- name = tool_call .function .name ,
679
- type = "function_call" ,
680
- )
681
- )
682
-
683
- return items
684
-
685
- @classmethod
686
- def maybe_easy_input_message (cls , item : Any ) -> EasyInputMessageParam | None :
687
- if not isinstance (item , dict ):
688
- return None
689
-
690
- keys = item .keys ()
691
- # EasyInputMessageParam only has these two keys
692
- if keys != {"content" , "role" }:
693
- return None
694
-
695
- role = item .get ("role" , None )
696
- if role not in ("user" , "assistant" , "system" , "developer" ):
697
- return None
698
-
699
- if "content" not in item :
700
- return None
701
-
702
- return cast (EasyInputMessageParam , item )
703
-
704
- @classmethod
705
- def maybe_input_message (cls , item : Any ) -> Message | None :
706
- if (
707
- isinstance (item , dict )
708
- and item .get ("type" ) == "message"
709
- and item .get ("role" )
710
- in (
711
- "user" ,
712
- "system" ,
713
- "developer" ,
714
- )
715
- ):
716
- return cast (Message , item )
717
-
718
- return None
719
-
720
- @classmethod
721
- def maybe_file_search_call (cls , item : Any ) -> ResponseFileSearchToolCallParam | None :
722
- if isinstance (item , dict ) and item .get ("type" ) == "file_search_call" :
723
- return cast (ResponseFileSearchToolCallParam , item )
724
- return None
725
-
726
- @classmethod
727
- def maybe_function_tool_call (cls , item : Any ) -> ResponseFunctionToolCallParam | None :
728
- if isinstance (item , dict ) and item .get ("type" ) == "function_call" :
729
- return cast (ResponseFunctionToolCallParam , item )
730
- return None
731
-
732
- @classmethod
733
- def maybe_function_tool_call_output (
734
- cls ,
735
- item : Any ,
736
- ) -> FunctionCallOutput | None :
737
- if isinstance (item , dict ) and item .get ("type" ) == "function_call_output" :
738
- return cast (FunctionCallOutput , item )
739
- return None
740
-
741
- @classmethod
742
- def maybe_item_reference (cls , item : Any ) -> ItemReference | None :
743
- if isinstance (item , dict ) and item .get ("type" ) == "item_reference" :
744
- return cast (ItemReference , item )
745
- return None
746
-
747
- @classmethod
748
- def maybe_response_output_message (cls , item : Any ) -> ResponseOutputMessageParam | None :
749
- # ResponseOutputMessage is only used for messages with role assistant
750
- if (
751
- isinstance (item , dict )
752
- and item .get ("type" ) == "message"
753
- and item .get ("role" ) == "assistant"
754
- ):
755
- return cast (ResponseOutputMessageParam , item )
756
- return None
757
-
758
- @classmethod
759
- def extract_text_content (
760
- cls , content : str | Iterable [ResponseInputContentParam ]
761
- ) -> str | list [ChatCompletionContentPartTextParam ]:
762
- all_content = cls .extract_all_content (content )
763
- if isinstance (all_content , str ):
764
- return all_content
765
- out : list [ChatCompletionContentPartTextParam ] = []
766
- for c in all_content :
767
- if c .get ("type" ) == "text" :
768
- out .append (cast (ChatCompletionContentPartTextParam , c ))
769
- return out
770
-
771
- @classmethod
772
- def extract_all_content (
773
- cls , content : str | Iterable [ResponseInputContentParam ]
774
- ) -> str | list [ChatCompletionContentPartParam ]:
775
- if isinstance (content , str ):
776
- return content
777
- out : list [ChatCompletionContentPartParam ] = []
778
-
779
- for c in content :
780
- if isinstance (c , dict ) and c .get ("type" ) == "input_text" :
781
- casted_text_param = cast (ResponseInputTextParam , c )
782
- out .append (
783
- ChatCompletionContentPartTextParam (
784
- type = "text" ,
785
- text = casted_text_param ["text" ],
786
- )
787
- )
788
- elif isinstance (c , dict ) and c .get ("type" ) == "input_image" :
789
- casted_image_param = cast (ResponseInputImageParam , c )
790
- if "image_url" not in casted_image_param or not casted_image_param ["image_url" ]:
791
- raise UserError (
792
- f"Only image URLs are supported for input_image { casted_image_param } "
793
- )
794
- out .append (
795
- ChatCompletionContentPartImageParam (
796
- type = "image_url" ,
797
- image_url = {
798
- "url" : casted_image_param ["image_url" ],
799
- "detail" : casted_image_param ["detail" ],
800
- },
801
- )
802
- )
803
- elif isinstance (c , dict ) and c .get ("type" ) == "input_file" :
804
- raise UserError (f"File uploads are not supported for chat completions { c } " )
805
- else :
806
- raise UserError (f"Unknown content: { c } " )
807
- return out
808
-
809
- @classmethod
810
- def items_to_messages (
811
- cls ,
812
- items : str | Iterable [TResponseInputItem ],
813
- ) -> list [ChatCompletionMessageParam ]:
814
- """
815
- Convert a sequence of 'Item' objects into a list of ChatCompletionMessageParam.
816
-
817
- Rules:
818
- - EasyInputMessage or InputMessage (role=user) => ChatCompletionUserMessageParam
819
- - EasyInputMessage or InputMessage (role=system) => ChatCompletionSystemMessageParam
820
- - EasyInputMessage or InputMessage (role=developer) => ChatCompletionDeveloperMessageParam
821
- - InputMessage (role=assistant) => Start or flush a ChatCompletionAssistantMessageParam
822
- - response_output_message => Also produces/flushes a ChatCompletionAssistantMessageParam
823
- - tool calls get attached to the *current* assistant message, or create one if none.
824
- - tool outputs => ChatCompletionToolMessageParam
825
- """
826
-
827
- if isinstance (items , str ):
828
- return [
829
- ChatCompletionUserMessageParam (
830
- role = "user" ,
831
- content = items ,
832
- )
833
- ]
834
-
835
- result : list [ChatCompletionMessageParam ] = []
836
- current_assistant_msg : ChatCompletionAssistantMessageParam | None = None
837
-
838
- def flush_assistant_message () -> None :
839
- nonlocal current_assistant_msg
840
- if current_assistant_msg is not None :
841
- # The API doesn't support empty arrays for tool_calls
842
- if not current_assistant_msg .get ("tool_calls" ):
843
- del current_assistant_msg ["tool_calls" ]
844
- result .append (current_assistant_msg )
845
- current_assistant_msg = None
846
-
847
- def ensure_assistant_message () -> ChatCompletionAssistantMessageParam :
848
- nonlocal current_assistant_msg
849
- if current_assistant_msg is None :
850
- current_assistant_msg = ChatCompletionAssistantMessageParam (role = "assistant" )
851
- current_assistant_msg ["tool_calls" ] = []
852
- return current_assistant_msg
853
-
854
- for item in items :
855
- # 1) Check easy input message
856
- if easy_msg := cls .maybe_easy_input_message (item ):
857
- role = easy_msg ["role" ]
858
- content = easy_msg ["content" ]
859
-
860
- if role == "user" :
861
- flush_assistant_message ()
862
- msg_user : ChatCompletionUserMessageParam = {
863
- "role" : "user" ,
864
- "content" : cls .extract_all_content (content ),
865
- }
866
- result .append (msg_user )
867
- elif role == "system" :
868
- flush_assistant_message ()
869
- msg_system : ChatCompletionSystemMessageParam = {
870
- "role" : "system" ,
871
- "content" : cls .extract_text_content (content ),
872
- }
873
- result .append (msg_system )
874
- elif role == "developer" :
875
- flush_assistant_message ()
876
- msg_developer : ChatCompletionDeveloperMessageParam = {
877
- "role" : "developer" ,
878
- "content" : cls .extract_text_content (content ),
879
- }
880
- result .append (msg_developer )
881
- elif role == "assistant" :
882
- flush_assistant_message ()
883
- msg_assistant : ChatCompletionAssistantMessageParam = {
884
- "role" : "assistant" ,
885
- "content" : cls .extract_text_content (content ),
886
- }
887
- result .append (msg_assistant )
888
- else :
889
- raise UserError (f"Unexpected role in easy_input_message: { role } " )
890
-
891
- # 2) Check input message
892
- elif in_msg := cls .maybe_input_message (item ):
893
- role = in_msg ["role" ]
894
- content = in_msg ["content" ]
895
- flush_assistant_message ()
896
-
897
- if role == "user" :
898
- msg_user = {
899
- "role" : "user" ,
900
- "content" : cls .extract_all_content (content ),
901
- }
902
- result .append (msg_user )
903
- elif role == "system" :
904
- msg_system = {
905
- "role" : "system" ,
906
- "content" : cls .extract_text_content (content ),
907
- }
908
- result .append (msg_system )
909
- elif role == "developer" :
910
- msg_developer = {
911
- "role" : "developer" ,
912
- "content" : cls .extract_text_content (content ),
913
- }
914
- result .append (msg_developer )
915
- else :
916
- raise UserError (f"Unexpected role in input_message: { role } " )
917
-
918
- # 3) response output message => assistant
919
- elif resp_msg := cls .maybe_response_output_message (item ):
920
- flush_assistant_message ()
921
- new_asst = ChatCompletionAssistantMessageParam (role = "assistant" )
922
- contents = resp_msg ["content" ]
923
-
924
- text_segments = []
925
- for c in contents :
926
- if c ["type" ] == "output_text" :
927
- text_segments .append (c ["text" ])
928
- elif c ["type" ] == "refusal" :
929
- new_asst ["refusal" ] = c ["refusal" ]
930
- elif c ["type" ] == "output_audio" :
931
- # Can't handle this, b/c chat completions expects an ID which we dont have
932
- raise UserError (
933
- f"Only audio IDs are supported for chat completions, but got: { c } "
934
- )
935
- else :
936
- raise UserError (f"Unknown content type in ResponseOutputMessage: { c } " )
937
-
938
- if text_segments :
939
- combined = "\n " .join (text_segments )
940
- new_asst ["content" ] = combined
941
-
942
- new_asst ["tool_calls" ] = []
943
- current_assistant_msg = new_asst
944
-
945
- # 4) function/file-search calls => attach to assistant
946
- elif file_search := cls .maybe_file_search_call (item ):
947
- asst = ensure_assistant_message ()
948
- tool_calls = list (asst .get ("tool_calls" , []))
949
- new_tool_call = ChatCompletionMessageToolCallParam (
950
- id = file_search ["id" ],
951
- type = "function" ,
952
- function = {
953
- "name" : "file_search_call" ,
954
- "arguments" : json .dumps (
955
- {
956
- "queries" : file_search .get ("queries" , []),
957
- "status" : file_search .get ("status" ),
958
- }
959
- ),
960
- },
961
- )
962
- tool_calls .append (new_tool_call )
963
- asst ["tool_calls" ] = tool_calls
964
-
965
- elif func_call := cls .maybe_function_tool_call (item ):
966
- asst = ensure_assistant_message ()
967
- tool_calls = list (asst .get ("tool_calls" , []))
968
- arguments = func_call ["arguments" ] if func_call ["arguments" ] else "{}"
969
- new_tool_call = ChatCompletionMessageToolCallParam (
970
- id = func_call ["call_id" ],
971
- type = "function" ,
972
- function = {
973
- "name" : func_call ["name" ],
974
- "arguments" : arguments ,
975
- },
976
- )
977
- tool_calls .append (new_tool_call )
978
- asst ["tool_calls" ] = tool_calls
979
- # 5) function call output => tool message
980
- elif func_output := cls .maybe_function_tool_call_output (item ):
981
- flush_assistant_message ()
982
- msg : ChatCompletionToolMessageParam = {
983
- "role" : "tool" ,
984
- "tool_call_id" : func_output ["call_id" ],
985
- "content" : func_output ["output" ],
986
- }
987
- result .append (msg )
988
-
989
- # 6) item reference => handle or raise
990
- elif item_ref := cls .maybe_item_reference (item ):
991
- raise UserError (
992
- f"Encountered an item_reference, which is not supported: { item_ref } "
993
- )
994
-
995
- # 7) If we haven't recognized it => fail or ignore
996
- else :
997
- raise UserError (f"Unhandled item type or structure: { item } " )
998
-
999
- flush_assistant_message ()
1000
- return result
1001
-
1002
-
1003
- class ToolConverter :
1004
- @classmethod
1005
- def to_openai (cls , tool : Tool ) -> ChatCompletionToolParam :
1006
- if isinstance (tool , FunctionTool ):
1007
- return {
1008
- "type" : "function" ,
1009
- "function" : {
1010
- "name" : tool .name ,
1011
- "description" : tool .description or "" ,
1012
- "parameters" : tool .params_json_schema ,
1013
- },
1014
- }
1015
-
1016
- raise UserError (
1017
- f"Hosted tools are not supported with the ChatCompletions API. Got tool type: "
1018
- f"{ type (tool )} , tool: { tool } "
1019
- )
1020
-
1021
- @classmethod
1022
- def convert_handoff_tool (cls , handoff : Handoff [Any ]) -> ChatCompletionToolParam :
1023
- return {
1024
- "type" : "function" ,
1025
- "function" : {
1026
- "name" : handoff .tool_name ,
1027
- "description" : handoff .tool_description ,
1028
- "parameters" : handoff .input_json_schema ,
1029
- },
1030
- }
0 commit comments