Skip to content

Commit eeb0519

Browse files
fix(server): Add tool calling params
1 parent 8082e74 commit eeb0519

6 files changed

Lines changed: 447 additions & 34 deletions

File tree

benchmark/mock_llm_server/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ class ChatCompletionRequest(BaseModel):
3939
presence_penalty: Optional[float] = Field(0.0, description="Presence penalty", ge=-2.0, le=2.0)
4040
frequency_penalty: Optional[float] = Field(0.0, description="Frequency penalty", ge=-2.0, le=2.0)
4141
logit_bias: Optional[dict[str, float]] = Field(None, description="Modify likelihood of specified tokens")
42+
tools: Optional[list[dict]] = Field(None, description="Tools parameter.")
43+
tool_choice: Optional[str | dict] = Field(None, description="Tool choice parameter.")
44+
parallel_tool_calls: Optional[bool] = Field(None, description="Whether to allow parallel tool calls.")
4245
user: Optional[str] = Field(None, description="Unique identifier representing your end-user")
4346

4447

nemoguardrails/server/api.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@
2828
import httpx
2929
from fastapi import FastAPI, HTTPException, Request
3030
from fastapi.middleware.cors import CORSMiddleware
31-
from openai.types.chat.chat_completion import Choice
32-
from openai.types.chat.chat_completion_message import ChatCompletionMessage
3331
from pydantic import BaseModel, ValidationError
3432
from starlette.responses import RedirectResponse, StreamingResponse
3533

@@ -43,11 +41,15 @@
4341
OpenAIModelsList,
4442
)
4543
from nemoguardrails.server.schemas.utils import (
44+
bot_message_to_chat_completion,
4645
create_error_chat_completion,
4746
extract_bot_message_from_response,
4847
fetch_models,
4948
format_streaming_chunk_as_sse,
5049
generation_response_to_chat_completion,
50+
normalize_tool_calls_openai,
51+
resolve_tool_calls,
52+
warn_if_thread_history_invalid_for_tool_use,
5153
)
5254

5355
try:
@@ -554,6 +556,7 @@ async def chat_completion(body: GuardrailsChatCompletionRequest, request: Reques
554556
# the string `thread-` to all thread keys.
555557
datastore_key = "thread-" + body.guardrails.thread_id
556558
thread_messages = json.loads(await datastore.get(datastore_key) or "[]")
559+
warn_if_thread_history_invalid_for_tool_use(thread_messages)
557560

558561
# And prepend them.
559562
messages = thread_messages + messages
@@ -577,6 +580,12 @@ async def chat_completion(body: GuardrailsChatCompletionRequest, request: Reques
577580
generation_options.llm_params["presence_penalty"] = body.presence_penalty
578581
if body.frequency_penalty is not None:
579582
generation_options.llm_params["frequency_penalty"] = body.frequency_penalty
583+
if body.tools is not None:
584+
generation_options.llm_params["tools"] = body.tools
585+
if body.tool_choice is not None:
586+
generation_options.llm_params["tool_choice"] = body.tool_choice
587+
if body.parallel_tool_calls is not None:
588+
generation_options.llm_params["parallel_tool_calls"] = body.parallel_tool_calls
580589

581590
if body.stream:
582591
# Use stream_async for streaming with output rails support
@@ -603,7 +612,15 @@ async def chat_completion(body: GuardrailsChatCompletionRequest, request: Reques
603612
# If we're using threads, we also need to update the data before returning
604613
# the message.
605614
if body.guardrails.thread_id and datastore is not None and datastore_key is not None:
606-
await datastore.set(datastore_key, json.dumps(messages + [bot_message]))
615+
# If using tool calls, we need to normalize them to OpenAI format before storing.
616+
response_tool_calls = res.tool_calls if isinstance(res, GenerationResponse) else None
617+
tool_calls_for_storage = resolve_tool_calls(bot_message, response_tool_calls)
618+
if tool_calls_for_storage:
619+
normalized = [tc.model_dump() for tc in normalize_tool_calls_openai(tool_calls_for_storage)]
620+
storable_message = {**bot_message, "tool_calls": normalized}
621+
else:
622+
storable_message = bot_message
623+
await datastore.set(datastore_key, json.dumps(messages + [storable_message]))
607624

608625
# Build the response with OpenAI-compatible format using utility function
609626
if isinstance(res, GenerationResponse):
@@ -613,23 +630,10 @@ async def chat_completion(body: GuardrailsChatCompletionRequest, request: Reques
613630
config_id=config_ids[0] if config_ids else None,
614631
)
615632
else:
616-
# For dict responses, convert to basic chat completion
617-
return GuardrailsChatCompletion(
618-
id=f"chatcmpl-{uuid.uuid4()}",
619-
object="chat.completion",
620-
created=int(time.time()),
633+
return bot_message_to_chat_completion(
634+
bot_message=bot_message,
621635
model=body.model,
622-
choices=[
623-
Choice(
624-
index=0,
625-
message=ChatCompletionMessage(
626-
role="assistant",
627-
content=bot_message.get("content", ""),
628-
),
629-
finish_reason="stop",
630-
logprobs=None,
631-
)
632-
],
636+
config_id=config_ids[0] if config_ids else None,
633637
)
634638

635639
except HTTPException:

nemoguardrails/server/schemas/openai.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,6 @@ class OpenAIChatCompletionRequest(BaseModel):
8282
default=None,
8383
description="Frequency penalty parameter.",
8484
)
85-
function_call: Optional[dict] = Field(
86-
default=None,
87-
description="Function call parameter.",
88-
)
8985
logit_bias: Optional[dict] = Field(
9086
default=None,
9187
description="Logit bias parameter.",
@@ -94,6 +90,18 @@ class OpenAIChatCompletionRequest(BaseModel):
9490
default=None,
9591
description="Log probabilities parameter.",
9692
)
93+
tools: Optional[list[dict]] = Field(
94+
default=None,
95+
description="Tools parameter.",
96+
)
97+
tool_choice: Optional[str | dict] = Field(
98+
default=None,
99+
description="Tool choice parameter.",
100+
)
101+
parallel_tool_calls: Optional[bool] = Field(
102+
default=None,
103+
description="Whether to allow parallel tool calls during tool use.",
104+
)
97105

98106

99107
class GuardrailsDataInput(BaseModel):

nemoguardrails/server/schemas/utils.py

Lines changed: 135 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
import httpx
2727
from openai.types.chat.chat_completion import Choice
2828
from 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

3034
from nemoguardrails.rails.llm.options import GenerationResponse
3135
from 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+
177279
def 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+
276404
def 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

Comments
 (0)