Skip to content
Open
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions ddtrace/contrib/internal/botocore/services/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ def _extract_request_params_for_invoke(params: dict[str, Any], provider: str) ->
"top_k": request_body.get("top_k", ""),
"max_tokens": request_body.get("max_tokens_to_sample", ""),
"stop_sequences": request_body.get("stop_sequences", []),
"tools": request_body.get("tools", []),
}
elif provider == _COHERE and "embed" in model_id:
return {
Expand Down
109 changes: 19 additions & 90 deletions ddtrace/llmobs/_integrations/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@
from ddtrace.llmobs._constants import TOTAL_TOKENS_METRIC_KEY
from ddtrace.llmobs._constants import UNKNOWN_MODEL_PROVIDER
from ddtrace.llmobs._integrations.base import BaseLLMIntegration
from ddtrace.llmobs._integrations.utils import anthropic_tool_call_from_block
from ddtrace.llmobs._integrations.utils import anthropic_tool_result_from_block
from ddtrace.llmobs._integrations.utils import format_image_part_with_guard
from ddtrace.llmobs._integrations.utils import get_messages_from_anthropic_content
from ddtrace.llmobs._integrations.utils import get_tool_definitions_from_anthropic_tools
from ddtrace.llmobs._integrations.utils import is_renderable_image_mime
from ddtrace.llmobs._utils import _annotate_llmobs_span_data
from ddtrace.llmobs._utils import _get_attr
from ddtrace.llmobs._utils import safe_json
from ddtrace.llmobs._utils import safe_load_json
from ddtrace.llmobs.types import Message
from ddtrace.llmobs.types import ToolCall
from ddtrace.llmobs.types import ToolDefinition
from ddtrace.llmobs.types import ToolResult
from ddtrace.trace import Span


Expand Down Expand Up @@ -191,91 +191,32 @@ def _extract_input_message(

elif "tool_use" in (content_type or ""):
text = _get_attr(block, "text", None)
input_data = _get_attr(block, "input", {})
if isinstance(input_data, str):
input_data = safe_load_json(input_data)
tool_call_info = ToolCall(
name=str(_get_attr(block, "name", "")),
arguments=input_data,
tool_id=str(_get_attr(block, "id", "")),
type=str(_get_attr(block, "type", "")),
)
if text is None:
text = ""
input_messages.append(Message(content=str(text), role=str(role), tool_calls=[tool_call_info]))
input_messages.append(
Message(
content=str(text),
role=str(role),
tool_calls=[anthropic_tool_call_from_block(block)],
)
)

elif "tool_result" in (content_type or ""):
content = _get_attr(block, "content", None)
formatted_content = self._format_tool_result_content(content)
tool_result_info = ToolResult(
result=formatted_content,
tool_id=str(_get_attr(block, "tool_use_id", "")),
type="tool_result",
input_messages.append(
Message(
content="",
role=str(role),
tool_results=[anthropic_tool_result_from_block(block)],
)
)
input_messages.append(Message(content="", role=str(role), tool_results=[tool_result_info]))
else:
input_messages.append(Message(content=str(block), role=str(role)))

return input_messages

def _format_tool_result_content(self, content) -> str:
if isinstance(content, str):
return content
elif isinstance(content, dict):
return safe_json(content)
elif isinstance(content, Iterable):
formatted_content = []
for tool_result_block in content:
if _get_attr(tool_result_block, "text", "") != "":
formatted_content.append(_get_attr(tool_result_block, "text", ""))
elif _get_attr(tool_result_block, "type", None) == "image":
# Store a placeholder for potentially enormous binary image data.
formatted_content.append(IMAGE_DETECTED_MARKER)
return ",".join(formatted_content)
return str(content)

def _extract_output_message(self, response) -> list[Message]:
"""Extract output messages from the stored response."""
output_messages: list[Message] = []
content = _get_attr(response, "content", "")
role = _get_attr(response, "role", "")

if isinstance(content, str):
return [Message(content=content, role=str(role))]

elif isinstance(content, list):
for completion in content:
completion_type = _get_attr(completion, "type", "") or ""
if completion_type == "thinking":
thinking_text = _get_attr(completion, "thinking", "")
output_messages.append(Message(content=str(thinking_text), role="reasoning"))
continue
text = _get_attr(completion, "text", None)
output_message = Message(content=str(text) if text else "", role=str(role))
if "tool_use" in completion_type:
input_data = _get_attr(completion, "input", {})
if isinstance(input_data, str):
input_data = safe_load_json(input_data)
tool_call_info = ToolCall(
name=str(_get_attr(completion, "name", "")),
arguments=input_data,
tool_id=str(_get_attr(completion, "id", "")),
type=str(completion_type),
)
output_message["tool_calls"] = [tool_call_info]
if "tool_result" in completion_type:
result = _get_attr(completion, "content", {})
if hasattr(result, "model_dump") and callable(result.model_dump):
result = result.model_dump()
formatted_result = self._format_tool_result_content(result)
tool_result_info = ToolResult(
result=formatted_result,
tool_id=str(_get_attr(completion, "tool_use_id", "")),
type="tool_result",
)
output_message["tool_results"] = [tool_result_info]
output_messages.append(output_message)
return output_messages
return get_messages_from_anthropic_content(_get_attr(response, "role", ""), _get_attr(response, "content", ""))

def _extract_usage(self, span: Span, usage: dict[str, Any]):
if not usage:
Expand Down Expand Up @@ -337,16 +278,4 @@ def _get_base_url(self, **kwargs: dict[str, Any]) -> Optional[str]:
return str(base_url) if base_url else None

def _extract_tools(self, tools: Optional[Any]) -> list[ToolDefinition]:
if not tools:
return []

tool_definitions = []
for tool in tools:
is_deferred = bool(_get_attr(tool, "defer_loading", False))
tool_def = ToolDefinition(
name=tool.get("name", ""),
description="" if is_deferred else tool.get("description", ""),
schema={} if is_deferred else tool.get("input_schema", {}),
)
tool_definitions.append(tool_def)
return tool_definitions
return get_tool_definitions_from_anthropic_tools(tools)
50 changes: 42 additions & 8 deletions ddtrace/llmobs/_integrations/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@
from ddtrace.llmobs._integrations.bedrock_agents import _max_finish_ns
from ddtrace.llmobs._integrations.bedrock_agents import translate_bedrock_trace
from ddtrace.llmobs._integrations.bedrock_utils import normalize_input_tokens
from ddtrace.llmobs._integrations.utils import anthropic_tool_call_from_block
from ddtrace.llmobs._integrations.utils import anthropic_tool_result_from_block
from ddtrace.llmobs._integrations.utils import get_final_message_converse_stream_message
from ddtrace.llmobs._integrations.utils import get_messages_from_anthropic_content
from ddtrace.llmobs._integrations.utils import get_messages_from_converse_content
from ddtrace.llmobs._integrations.utils import get_tool_definitions_from_anthropic_tools
from ddtrace.llmobs._utils import _annotate_llmobs_span_data
from ddtrace.llmobs._utils import _get_attr
from ddtrace.llmobs.types import Message
Expand Down Expand Up @@ -90,10 +94,15 @@ def _llmobs_set_tags(
metadata["max_tokens"] = int(request_params.get("max_tokens") or 0)

prompt = request_params.get("prompt", "")
tool_config = request_params.get("tool_config", {})
tool_definitions = self._extract_tool_definitions(tool_config)

is_converse = ctx["resource"] in ("Converse", "ConverseStream")
# Converse wraps definitions in `toolConfig.tools[].toolSpec`; InvokeModel carries
# the provider's own format, which for Anthropic models is `tools[]`.
tool_definitions = (
self._extract_tool_definitions(request_params.get("tool_config", {}))
if is_converse
else get_tool_definitions_from_anthropic_tools(request_params.get("tools", []))
)
input_messages = (
self._extract_input_message_for_converse(prompt) if is_converse else self._extract_input_message(prompt)
)
Expand Down Expand Up @@ -378,20 +387,45 @@ def _extract_input_message(prompt) -> list[Message]:
for p in prompt:
content = p.get("content", "")
if isinstance(content, list) and isinstance(content[0], dict):
role = str(p.get("role", ""))
for entry in content:
if entry.get("type") == "text":
input_messages.append(Message(content=entry.get("text", ""), role=str(p.get("role", ""))))
elif entry.get("type") == "image":
entry_type = entry.get("type", "") or ""
if entry_type == "text":
input_messages.append(Message(content=entry.get("text", ""), role=role))
elif entry_type == "image":
# Store a placeholder for potentially enormous binary image data.
input_messages.append(Message(content=IMAGE_DETECTED_MARKER, role=str(p.get("role", ""))))
input_messages.append(Message(content=IMAGE_DETECTED_MARKER, role=role))
elif entry_type == "thinking":
input_messages.append(Message(content=str(entry.get("thinking", "")), role="reasoning"))
elif "tool_use" in entry_type:
input_messages.append(
Message(
content=str(entry.get("text", "") or ""),
role=role,
tool_calls=[anthropic_tool_call_from_block(entry)],
)
)
elif "tool_result" in entry_type:
input_messages.append(
Message(
content="",
role=role,
tool_results=[anthropic_tool_result_from_block(entry)],
)
)
else:
input_messages.append(Message(content=str(content), role=str(p.get("role", ""))))
return input_messages

@staticmethod
def _extract_output_message(response) -> list[Message]:
"""Extract output messages from the stored response.
Anthropic allows for chat messages, which requires some special casing.

Most Bedrock providers return plain text. Anthropic models return an
Anthropic Messages API `content` field: a list of tagged content blocks
(`text`, `thinking`, `tool_use`, ...), which is handed to the shared
Anthropic extractor so InvokeModel captures the same data the Anthropic
SDK and Converse integrations do.
"""
resp_text = response.get("text", "")
if isinstance(resp_text, str):
Expand All @@ -400,7 +434,7 @@ def _extract_output_message(response) -> list[Message]:
if isinstance(resp_text[0], str):
return [Message(content=str(content)) for content in resp_text]
if isinstance(resp_text[0], dict):
return [Message(content=resp_text[0].get("text", ""))]
return get_messages_from_anthropic_content("assistant", resp_text)
return []

def _get_base_url(self, **kwargs: dict[str, Any]) -> Optional[str]:
Expand Down
109 changes: 109 additions & 0 deletions ddtrace/llmobs/_integrations/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import re
from typing import Any
from typing import Iterable
from typing import Optional
from typing import Union

Expand All @@ -16,6 +17,7 @@
from ddtrace.llmobs._constants import DISPATCH_ON_LLM_TOOL_CHOICE
from ddtrace.llmobs._constants import DISPATCH_ON_TOOL_CALL_OUTPUT_USED
from ddtrace.llmobs._constants import FILE_FALLBACK_MARKER
from ddtrace.llmobs._constants import IMAGE_DETECTED_MARKER
from ddtrace.llmobs._constants import IMAGE_FALLBACK_MARKER
from ddtrace.llmobs._constants import IMAGE_TOO_LARGE_MARKER
from ddtrace.llmobs._constants import INPUT_COST_METRIC_KEY
Expand Down Expand Up @@ -294,6 +296,113 @@ def get_content_from_langchain_message(message) -> Union[str, tuple[str, str]]:
return str(message)


def format_anthropic_tool_result_content(content) -> str:
"""Flatten the `content` field of an Anthropic `tool_result` block into a string."""
if isinstance(content, str):
return content
elif isinstance(content, dict):
return safe_json(content)
elif isinstance(content, Iterable):
formatted_content = []
for tool_result_block in content:
if _get_attr(tool_result_block, "text", "") != "":
formatted_content.append(_get_attr(tool_result_block, "text", ""))
elif _get_attr(tool_result_block, "type", None) == "image":
# Store a placeholder for potentially enormous binary image data.
formatted_content.append(IMAGE_DETECTED_MARKER)
return ",".join(formatted_content)
return str(content)


def get_tool_definitions_from_anthropic_tools(tools: Optional[Any]) -> list[ToolDefinition]:
"""Build tool definitions from an Anthropic `tools` request field.

Used by the Anthropic SDK integration and by Bedrock `InvokeModel`, which sends
Anthropic-formatted tool definitions rather than the Converse `toolConfig` shape.
"""
if not tools:
return []

tool_definitions = []
for tool in tools:
is_deferred = bool(_get_attr(tool, "defer_loading", False))
tool_definitions.append(
ToolDefinition(
name=tool.get("name", ""),
description="" if is_deferred else tool.get("description", ""),
schema={} if is_deferred else tool.get("input_schema", {}),
)
)
return tool_definitions


def anthropic_tool_call_from_block(block: Any) -> ToolCall:
"""Build a ToolCall from an Anthropic `tool_use` content block.

`input` arrives as a dict on complete responses and as an accumulated JSON string
when reassembled from streaming deltas, so it is normalized to a dict here.
"""
input_data = _get_attr(block, "input", {})
if isinstance(input_data, str):
input_data = safe_load_json(input_data)
return ToolCall(
name=str(_get_attr(block, "name", "")),
arguments=input_data,
tool_id=str(_get_attr(block, "id", "")),
type=str(_get_attr(block, "type", "")),
)


def anthropic_tool_result_from_block(block: Any) -> ToolResult:
"""Build a ToolResult from an Anthropic `tool_result` content block."""
result = _get_attr(block, "content", {})
if hasattr(result, "model_dump") and callable(result.model_dump):
result = result.model_dump()
return ToolResult(
result=format_anthropic_tool_result_content(result),
tool_id=str(_get_attr(block, "tool_use_id", "")),
type="tool_result",
)


def get_messages_from_anthropic_content(role: str, content: Any) -> list[Message]:
"""
Extracts out a list of messages from an Anthropic Messages API `content` field.

`content` is either a string or a list of content blocks. Each block is a tagged union
discriminated by `type`, and the payload lives under a different key per type
(`text`, `thinking`, `input`/`name` for `tool_use`, `content` for `tool_result`).

Used by both the Anthropic SDK integration and the Bedrock `InvokeModel` integration,
since Bedrock passes Anthropic-formatted request and response bodies through unchanged.

For more info, see the Anthropic Messages API spec:
https://docs.anthropic.com/en/api/messages
"""
if isinstance(content, str):
return [Message(content=content, role=str(role))]
if not isinstance(content, list):
return []

output_messages: list[Message] = []
for block in content:
block_type = _get_attr(block, "type", "") or ""
if block_type == "thinking":
thinking_text = _get_attr(block, "thinking", "")
output_messages.append(Message(content=str(thinking_text), role="reasoning"))
continue
text = _get_attr(block, "text", None)
message = Message(content=str(text) if text else "", role=str(role))
# Substring match: Anthropic also emits `server_tool_use`, `mcp_tool_use`,
# and `web_search_tool_result`, which carry the same payload shape.
if "tool_use" in block_type:
message["tool_calls"] = [anthropic_tool_call_from_block(block)]
if "tool_result" in block_type:
message["tool_results"] = [anthropic_tool_result_from_block(block)]
output_messages.append(message)
return output_messages


def get_messages_from_converse_content(role: str, content: list[dict[str, Any]]) -> list[Message]:
"""
Extracts out a list of messages from a converse `content` field.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
fixes:
- |
LLM Observability: botocore: This fix resolves an issue where the Amazon Bedrock
``InvokeModel`` integration dropped ``tool_use``, ``tool_result``, and ``thinking``
blocks from Anthropic input messages, so replayed conversation history in agent
loops was missing its tool turns.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
features:
- |
LLM Observability: botocore: The Amazon Bedrock integration now captures tool calls from
``InvokeModel`` responses for Anthropic models, matching the ``Converse`` API.
fixes:
- |
LLM Observability: botocore: This fix resolves an issue where the Amazon Bedrock
``InvokeModel`` integration only captured the first content block of an Anthropic
response, so output messages were empty or truncated when a response began with a
``tool_use`` or ``thinking`` block.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fixes:
- |
LLM Observability: botocore: This fix resolves an issue where tool definitions were
not captured on Amazon Bedrock ``InvokeModel`` spans for Anthropic models, which send
tool definitions in the request body rather than in the Converse ``toolConfig`` field.
Loading
Loading