Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 53 additions & 13 deletions livekit-plugins/livekit-plugins-aws/livekit/plugins/aws/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# limitations under the License.
from __future__ import annotations

import json
import os
from dataclasses import dataclass
from typing import Any
Expand All @@ -36,6 +37,37 @@
DEFAULT_TEXT_MODEL = "amazon.nova-2-lite-v1:0"


def _flatten_tool_blocks(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Rewrite toolUse/toolResult content blocks as plain text.

Bedrock Converse rejects requests that contain tool blocks without a
matching ``toolConfig``. When tools are intentionally omitted from a turn
(e.g. ``tool_choice="none"``), this preserves the history as readable text
instead so the model still sees what happened on prior turns.
"""
flattened: list[dict[str, Any]] = []
for msg in messages:
new_content: list[dict[str, Any]] = []
for block in msg.get("content", []):
if "toolUse" in block:
tu = block["toolUse"]
args = json.dumps(tu.get("input", {}), sort_keys=True)
new_content.append({"text": f"[Called {tu.get('name', '')} with {args}]"})
elif "toolResult" in block:
tr = block["toolResult"]
parts: list[str] = []
for item in tr.get("content", []):
if "text" in item:
parts.append(item["text"])
elif "json" in item:
parts.append(json.dumps(item["json"], sort_keys=True))
new_content.append({"text": f"[Tool output: {' '.join(parts)}]"})
else:
new_content.append(block)
flattened.append({**msg, "content": new_content})
return flattened


@dataclass
class _LLMOptions:
model: str
Expand Down Expand Up @@ -139,34 +171,45 @@ def chat(
if is_given(self._opts.model):
opts["modelId"] = self._opts.model

def _get_tool_config() -> dict[str, Any] | None:
nonlocal tool_choice
effective_tool_choice = tool_choice if is_given(tool_choice) else self._opts.tool_choice

def _get_tool_config() -> dict[str, Any] | None:
if not tools:
return None

# Bedrock's toolChoice only accepts auto/any/tool — no "none" equivalent.
# When the caller wants no tools for this turn, drop toolConfig entirely;
# the matching toolUse/toolResult blocks in history are flattened to text
# below so Bedrock doesn't reject the request.
if is_given(effective_tool_choice) and effective_tool_choice == "none":
return None

tools_list = llm.ToolContext(tools).parse_function_tools("aws")
if self._opts.cache_tools:
tools_list.append({"cachePoint": {"type": "default"}})

tool_config: dict[str, Any] = {"tools": tools_list}
tool_choice = tool_choice if is_given(tool_choice) else self._opts.tool_choice
if is_given(tool_choice):
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
tool_config["toolChoice"] = {"tool": {"name": tool_choice["function"]["name"]}}
elif tool_choice == "required":
if is_given(effective_tool_choice):
if (
isinstance(effective_tool_choice, dict)
and effective_tool_choice.get("type") == "function"
):
tool_config["toolChoice"] = {
"tool": {"name": effective_tool_choice["function"]["name"]}
}
elif effective_tool_choice == "required":
tool_config["toolChoice"] = {"any": {}}
elif tool_choice == "auto":
elif effective_tool_choice == "auto":
tool_config["toolChoice"] = {"auto": {}}
else:
return None

return tool_config

tool_config = _get_tool_config()
if tool_config:
opts["toolConfig"] = tool_config
messages, extra_data = chat_ctx.to_provider_format(format="aws")
if tool_config is None:
messages = _flatten_tool_blocks(messages)
opts["messages"] = messages
if extra_data.system_messages:
system_messages: list[dict[str, str | dict]] = [
Expand Down Expand Up @@ -286,9 +329,6 @@ def _parse_chunk(self, request_id: str, chunk: dict) -> llm.ChatChunk | None:
)
elif "contentBlockStop" in chunk:
if self._tool_call_id:
if self._tool_call_id is None:
logger.warning("aws bedrock llm: no tool call id in the response")
return None
if self._fnc_name is None:
logger.warning("aws bedrock llm: no function name in the response")
return None
Expand Down
Loading