2626import httpx
2727from openai .types .chat .chat_completion import Choice
2828from openai .types .chat .chat_completion_message import ChatCompletionMessage
29+ from openai .types .chat .chat_completion_message_tool_call import (
30+ ChatCompletionMessageToolCall ,
31+ Function ,
32+ )
2933
3034from nemoguardrails .rails .llm .options import GenerationResponse
3135from nemoguardrails .server .schemas .openai import (
@@ -174,6 +178,104 @@ async def fetch_models(
174178 ]
175179
176180
181+ def _parse_tool_call_name_and_arguments (tc : dict ) -> tuple [str , str ]:
182+ if "function" in tc :
183+ func = tc .get ("function" ) or {}
184+ name = func .get ("name" , "" )
185+ arguments = func .get ("arguments" , {})
186+ else :
187+ name = tc .get ("name" , "" )
188+ arguments = tc .get ("args" , {})
189+
190+ if isinstance (arguments , dict ):
191+ arguments_str = json .dumps (arguments )
192+ elif isinstance (arguments , str ):
193+ arguments_str = arguments
194+ else :
195+ arguments_str = json .dumps (arguments )
196+ return name , arguments_str
197+
198+
199+ def _generate_fallback_tool_call_id (tc : dict ) -> str :
200+ fallback_id = f"call_{ uuid .uuid4 ().hex [:8 ]} "
201+ func_name = tc .get ("name" ) or (tc .get ("function" ) or {}).get ("name" , "<unknown>" )
202+ log .warning (
203+ "Tool call for function %r is missing an 'id'; generated fallback id %r." ,
204+ func_name ,
205+ fallback_id ,
206+ )
207+ return fallback_id
208+
209+
210+ def normalize_tool_calls_openai (
211+ tool_calls : List [dict ],
212+ ) -> List [ChatCompletionMessageToolCall ]:
213+ """Convert internal tool call dicts to OpenAI function tool call objects."""
214+ openai_tool_calls : List [ChatCompletionMessageToolCall ] = []
215+ for tc in tool_calls :
216+ name , arguments_str = _parse_tool_call_name_and_arguments (tc )
217+ openai_tool_calls .append (
218+ ChatCompletionMessageToolCall (
219+ id = tc .get ("id" ) or _generate_fallback_tool_call_id (tc ),
220+ type = "function" ,
221+ function = Function (name = name , arguments = arguments_str ),
222+ )
223+ )
224+ return openai_tool_calls
225+
226+
227+ def resolve_tool_calls (bot_message : dict , response_tool_calls : Optional [list ] = None ) -> Optional [List [dict ]]:
228+ """Collect tool calls from a bot message and/or GenerationResponse.tool_calls."""
229+ tool_calls = bot_message .get ("tool_calls" ) or response_tool_calls
230+ return tool_calls or None
231+
232+
233+ def build_chat_completion_message (
234+ bot_message : dict ,
235+ tool_calls : Optional [List [dict ]] = None ,
236+ ) -> ChatCompletionMessage :
237+ """Build an OpenAI ChatCompletionMessage from an internal bot message dict."""
238+ content = bot_message .get ("content" )
239+ if content == "" and tool_calls :
240+ content = None
241+
242+ if not tool_calls :
243+ return ChatCompletionMessage (
244+ role = "assistant" ,
245+ content = content ,
246+ )
247+
248+ openai_tool_calls = normalize_tool_calls_openai (tool_calls )
249+
250+ return ChatCompletionMessage (
251+ role = "assistant" ,
252+ content = content ,
253+ tool_calls = openai_tool_calls , # pyright: ignore[reportArgumentType]
254+ )
255+
256+
257+ def warn_if_thread_history_invalid_for_tool_use (messages : List [dict ]) -> None :
258+ """Log when persisted thread history is incompatible with OpenAI tool-calling message ordering."""
259+ for i , msg in enumerate (messages ):
260+ if msg .get ("role" ) != "tool" :
261+ continue
262+ if i == 0 or messages [i - 1 ].get ("role" ) != "assistant" :
263+ log .warning (
264+ "Thread message history has a tool message without a preceding assistant "
265+ "message at index %s. Multi-turn tool use with thread_id is unreliable; "
266+ "send the full message list (assistant tool_calls + tool results) in each request." ,
267+ i ,
268+ )
269+ continue
270+ if not messages [i - 1 ].get ("tool_calls" ):
271+ log .warning (
272+ "Thread message history has a tool message after an assistant message "
273+ "without tool_calls at index %s. Prior assistant tool_calls may have been "
274+ "dropped when the thread was saved; include full history in the request." ,
275+ i ,
276+ )
277+
278+
177279def extract_bot_message_from_response (
178280 response : Union [str , dict , GenerationResponse , Tuple [dict , dict ]],
179281) -> Dict [str , Any ]:
@@ -194,9 +296,10 @@ def extract_bot_message_from_response(
194296 bot_message_content = response .response [0 ]
195297 # Ensure bot_message is always a dict
196298 if isinstance (bot_message_content , str ):
197- return {"role" : "assistant" , "content" : bot_message_content }
299+ bot_message = {"role" : "assistant" , "content" : bot_message_content }
198300 else :
199- return bot_message_content
301+ bot_message = bot_message_content
302+ return bot_message
200303 elif isinstance (response , str ):
201304 # Direct string response
202305 return {"role" : "assistant" , "content" : response }
@@ -229,6 +332,8 @@ def generation_response_to_chat_completion(
229332 A GuardrailsChatCompletion instance compatible with OpenAI API format
230333 """
231334 bot_message = extract_bot_message_from_response (response )
335+ tool_calls = resolve_tool_calls (bot_message , response .tool_calls )
336+ finish_reason = "tool_calls" if tool_calls else "stop"
232337
233338 # Convert log to dict if present (for JSON serialization)
234339 log_dict = None
@@ -255,11 +360,8 @@ def generation_response_to_chat_completion(
255360 choices = [
256361 Choice (
257362 index = 0 ,
258- message = ChatCompletionMessage (
259- role = "assistant" ,
260- content = bot_message .get ("content" , "" ),
261- ),
262- finish_reason = "stop" ,
363+ message = build_chat_completion_message (bot_message , tool_calls ),
364+ finish_reason = finish_reason ,
263365 logprobs = None ,
264366 )
265367 ],
@@ -273,6 +375,32 @@ def generation_response_to_chat_completion(
273375 )
274376
275377
378+ def bot_message_to_chat_completion (
379+ bot_message : dict ,
380+ model : str ,
381+ config_id : Optional [str ] = None ,
382+ ) -> GuardrailsChatCompletion :
383+ """Convert a bot message dict to an OpenAI-compatible GuardrailsChatCompletion."""
384+ tool_calls = resolve_tool_calls (bot_message )
385+ finish_reason = "tool_calls" if tool_calls else "stop"
386+
387+ return GuardrailsChatCompletion (
388+ id = f"chatcmpl-{ uuid .uuid4 ()} " ,
389+ object = "chat.completion" ,
390+ created = int (time .time ()),
391+ model = model ,
392+ choices = [
393+ Choice (
394+ index = 0 ,
395+ message = build_chat_completion_message (bot_message , tool_calls ),
396+ finish_reason = finish_reason ,
397+ logprobs = None ,
398+ )
399+ ],
400+ guardrails = GuardrailsDataOutput (config_id = config_id ) if config_id else None ,
401+ )
402+
403+
276404def create_error_chat_completion (
277405 model : str ,
278406 error_message : str ,
@@ -330,7 +458,6 @@ def format_streaming_chunk(
330458
331459 # Determine the payload format based on chunk type
332460 if isinstance (chunk , dict ):
333- # If chunk is a dict, wrap it in OpenAI chunk format with delta
334461 return {
335462 "id" : chunk_id ,
336463 "object" : "chat.completion.chunk" ,
0 commit comments