Python: [Breaking] Simplified Content types to a single class with classmethod constructors. (#3252)

* ported Content to a new model

* fixed linting

* fixes

* fixed data format handling

* fix for 3.10 mypy

* fix

* fix int test
This commit is contained in:
Eduard van Valkenburg
2026-01-20 22:09:39 +00:00
committed by GitHub
parent 73761aa4a3
commit 83e6229c11
132 changed files with 3949 additions and 4741 deletions
@@ -7,31 +7,19 @@ from typing import Any, ClassVar, Final, Generic, Literal, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AIFunction,
Annotations,
Annotation,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
Contents,
ErrorContent,
Content,
FinishReason,
FunctionCallContent,
FunctionResultContent,
HostedCodeInterpreterTool,
HostedFileContent,
HostedMCPTool,
HostedWebSearchTool,
MCPServerToolCallContent,
MCPServerToolResultContent,
Role,
TextContent,
TextReasoningContent,
TextSpanRegion,
UsageContent,
UsageDetails,
get_logger,
prepare_function_call_results,
@@ -486,7 +474,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
a_content.append({
"type": "image",
"source": {
"data": content.get_data_bytes_as_str(),
"data": content.get_data_bytes_as_str(), # type: ignore[attr-defined]
"media_type": content.media_type,
"type": "base64",
},
@@ -653,9 +641,9 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
"""
match event.type:
case "message_start":
usage_details: list[UsageContent] = []
usage_details: list[Content] = []
if event.message.usage and (details := self._parse_usage_from_anthropic(event.message.usage)):
usage_details.append(UsageContent(details=details))
usage_details.append(Content.from_usage(usage_details=details))
return ChatResponseUpdate(
response_id=event.message.id,
@@ -672,7 +660,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
case "message_delta":
usage = self._parse_usage_from_anthropic(event.usage)
return ChatResponseUpdate(
contents=[UsageContent(details=usage, raw_representation=event.usage)] if usage else [],
contents=[Content.from_usage(usage_details=usage, raw_representation=event.usage)] if usage else [],
finish_reason=FINISH_REASON_MAP.get(event.delta.stop_reason) if event.delta.stop_reason else None,
raw_representation=event,
)
@@ -702,24 +690,24 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
return None
usage_details = UsageDetails(output_token_count=usage.output_tokens)
if usage.input_tokens is not None:
usage_details.input_token_count = usage.input_tokens
usage_details["input_token_count"] = usage.input_tokens
if usage.cache_creation_input_tokens is not None:
usage_details.additional_counts["anthropic.cache_creation_input_tokens"] = usage.cache_creation_input_tokens
usage_details["anthropic.cache_creation_input_tokens"] = usage.cache_creation_input_tokens # type: ignore[typeddict-unknown-key]
if usage.cache_read_input_tokens is not None:
usage_details.additional_counts["anthropic.cache_read_input_tokens"] = usage.cache_read_input_tokens
usage_details["anthropic.cache_read_input_tokens"] = usage.cache_read_input_tokens # type: ignore[typeddict-unknown-key]
return usage_details
def _parse_contents_from_anthropic(
self,
content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock],
) -> list[Contents]:
) -> list[Content]:
"""Parse contents from the Anthropic message."""
contents: list[Contents] = []
contents: list[Content] = []
for content_block in content:
match content_block.type:
case "text" | "text_delta":
contents.append(
TextContent(
Content.from_text(
text=content_block.text,
raw_representation=content_block,
annotations=self._parse_citations_from_anthropic(content_block),
@@ -729,7 +717,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
self._last_call_id_name = (content_block.id, content_block.name)
if content_block.type == "mcp_tool_use":
contents.append(
MCPServerToolCallContent(
Content.from_mcp_server_tool_call(
call_id=content_block.id,
tool_name=content_block.name,
server_name=None,
@@ -739,10 +727,10 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
elif "code_execution" in (content_block.name or ""):
contents.append(
CodeInterpreterToolCallContent(
Content.from_code_interpreter_tool_call(
call_id=content_block.id,
inputs=[
TextContent(
Content.from_text(
text=str(content_block.input),
raw_representation=content_block,
)
@@ -752,7 +740,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
else:
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=content_block.id,
name=content_block.name,
arguments=content_block.input,
@@ -760,14 +748,14 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
)
case "mcp_tool_result":
call_id, name = self._last_call_id_name or (None, None)
parsed_output: list[Contents] | None = None
call_id, _ = self._last_call_id_name or (None, None)
parsed_output: list[Content] | None = None
if content_block.content:
if isinstance(content_block.content, list):
parsed_output = self._parse_contents_from_anthropic(content_block.content)
elif isinstance(content_block.content, (str, bytes)):
parsed_output = [
TextContent(
Content.from_text(
text=str(content_block.content),
raw_representation=content_block,
)
@@ -775,28 +763,27 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else:
parsed_output = self._parse_contents_from_anthropic([content_block.content])
contents.append(
MCPServerToolResultContent(
Content.from_mcp_server_tool_result(
call_id=content_block.tool_use_id,
output=parsed_output,
raw_representation=content_block,
)
)
case "web_search_tool_result" | "web_fetch_tool_result":
call_id, name = self._last_call_id_name or (None, None)
call_id, _ = self._last_call_id_name or (None, None)
contents.append(
FunctionResultContent(
Content.from_function_result(
call_id=content_block.tool_use_id,
name=name if name and call_id == content_block.tool_use_id else "web_tool",
result=content_block.content,
raw_representation=content_block,
)
)
case "code_execution_tool_result":
code_outputs: list[Contents] = []
code_outputs: list[Content] = []
if content_block.content:
if isinstance(content_block.content, BetaCodeExecutionToolResultError):
code_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.error_code,
raw_representation=content_block.content,
)
@@ -804,41 +791,41 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else:
if content_block.content.stdout:
code_outputs.append(
TextContent(
Content.from_text(
text=content_block.content.stdout,
raw_representation=content_block.content,
)
)
if content_block.content.stderr:
code_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.stderr,
raw_representation=content_block.content,
)
)
for code_file_content in content_block.content.content:
code_outputs.append(
HostedFileContent(
Content.from_hosted_file(
file_id=code_file_content.file_id,
raw_representation=code_file_content,
)
)
contents.append(
CodeInterpreterToolResultContent(
Content.from_code_interpreter_tool_result(
call_id=content_block.tool_use_id,
raw_representation=content_block,
outputs=code_outputs,
)
)
case "bash_code_execution_tool_result":
bash_outputs: list[Contents] = []
bash_outputs: list[Content] = []
if content_block.content:
if isinstance(
content_block.content,
BetaBashCodeExecutionToolResultError,
):
bash_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.error_code,
raw_representation=content_block.content,
)
@@ -846,39 +833,38 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else:
if content_block.content.stdout:
bash_outputs.append(
TextContent(
Content.from_text(
text=content_block.content.stdout,
raw_representation=content_block.content,
)
)
if content_block.content.stderr:
bash_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.stderr,
raw_representation=content_block.content,
)
)
for bash_file_content in content_block.content.content:
contents.append(
HostedFileContent(
Content.from_hosted_file(
file_id=bash_file_content.file_id,
raw_representation=bash_file_content,
)
)
contents.append(
FunctionResultContent(
Content.from_function_result(
call_id=content_block.tool_use_id,
name=content_block.type,
result=bash_outputs,
raw_representation=content_block,
)
)
case "text_editor_code_execution_tool_result":
text_editor_outputs: list[Contents] = []
text_editor_outputs: list[Content] = []
match content_block.content.type:
case "text_editor_code_execution_tool_result_error":
text_editor_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.error_code
and getattr(content_block.content, "error_message", ""),
raw_representation=content_block.content,
@@ -887,10 +873,12 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
case "text_editor_code_execution_view_result":
annotations = (
[
CitationAnnotation(
Annotation(
type="citation",
raw_representation=content_block.content,
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=content_block.content.start_line,
end_index=content_block.content.start_line
+ (content_block.content.num_lines or 0),
@@ -903,7 +891,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else None
)
text_editor_outputs.append(
TextContent(
Content.from_text(
text=content_block.content.content,
annotations=annotations,
raw_representation=content_block.content,
@@ -911,10 +899,12 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
case "text_editor_code_execution_str_replace_result":
old_annotation = (
CitationAnnotation(
Annotation(
type="citation",
raw_representation=content_block.content,
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=content_block.content.old_start or 0,
end_index=(
(content_block.content.old_start or 0)
@@ -928,13 +918,15 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else None
)
new_annotation = (
CitationAnnotation(
Annotation(
type="citation",
raw_representation=content_block.content,
snippet="\n".join(content_block.content.lines)
snippet="\n".join(content_block.content.lines) # type: ignore[typeddict-item]
if content_block.content.lines
else None,
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=content_block.content.new_start or 0,
end_index=(
(content_block.content.new_start or 0)
@@ -950,7 +942,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
annotations = [ann for ann in [old_annotation, new_annotation] if ann is not None]
text_editor_outputs.append(
TextContent(
Content.from_text(
text=(
"\n".join(content_block.content.lines) if content_block.content.lines else ""
),
@@ -960,15 +952,14 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
case "text_editor_code_execution_create_result":
text_editor_outputs.append(
TextContent(
Content.from_text(
text=f"File update: {content_block.content.is_file_update}",
raw_representation=content_block.content,
)
)
contents.append(
FunctionResultContent(
Content.from_function_result(
call_id=content_block.tool_use_id,
name=content_block.type,
result=text_editor_outputs,
raw_representation=content_block,
)
@@ -981,7 +972,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
# This matches OpenAI's behavior where streaming chunks have name="".
call_id, _ = self._last_call_id_name if self._last_call_id_name else ("", "")
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=call_id,
name="",
arguments=content_block.partial_json,
@@ -990,7 +981,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
case "thinking" | "thinking_delta":
contents.append(
TextReasoningContent(
Content.from_text_reasoning(
text=content_block.thinking,
raw_representation=content_block,
)
@@ -1001,65 +992,65 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
def _parse_citations_from_anthropic(
self, content_block: BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock
) -> list[Annotations] | None:
content_citations = getattr(content_block, "citations", None)
if not content_citations:
) -> list[Annotation] | None:
content_blocks = getattr(content_block, "citations", None)
if not content_blocks:
return None
annotations: list[Annotations] = []
for citation in content_citations:
cit = CitationAnnotation(raw_representation=citation)
annotations: list[Annotation] = []
for citation in content_blocks:
cit = Annotation(type="citation", raw_representation=citation)
match citation.type:
case "char_location":
cit.title = citation.title
cit.snippet = citation.cited_text
cit["title"] = citation.title
cit["snippet"] = citation.cited_text
if citation.file_id:
cit.file_id = citation.file_id
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
cit["file_id"] = citation.file_id
cit.setdefault("annotated_regions", [])
cit["annotated_regions"].append( # type: ignore[attr-defined]
TextSpanRegion(
type="text_span",
start_index=citation.start_char_index,
end_index=citation.end_char_index,
)
)
case "page_location":
cit.title = citation.document_title
cit.snippet = citation.cited_text
cit["title"] = citation.document_title
cit["snippet"] = citation.cited_text
if citation.file_id:
cit.file_id = citation.file_id
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
cit["file_id"] = citation.file_id
cit.setdefault("annotated_regions", [])
cit["annotated_regions"].append( # type: ignore[attr-defined]
TextSpanRegion(
type="text_span",
start_index=citation.start_page_number,
end_index=citation.end_page_number,
)
)
case "content_block_location":
cit.title = citation.document_title
cit.snippet = citation.cited_text
cit["title"] = citation.document_title
cit["snippet"] = citation.cited_text
if citation.file_id:
cit.file_id = citation.file_id
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
cit["file_id"] = citation.file_id
cit.setdefault("annotated_regions", [])
cit["annotated_regions"].append( # type: ignore[attr-defined]
TextSpanRegion(
type="text_span",
start_index=citation.start_block_index,
end_index=citation.end_block_index,
)
)
case "web_search_result_location":
cit.title = citation.title
cit.snippet = citation.cited_text
cit.url = citation.url
cit["title"] = citation.title
cit["snippet"] = citation.cited_text
cit["url"] = citation.url
case "search_result_location":
cit.title = citation.title
cit.snippet = citation.cited_text
cit.url = citation.source
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
cit["title"] = citation.title
cit["snippet"] = citation.cited_text
cit["url"] = citation.source
cit.setdefault("annotated_regions", [])
cit["annotated_regions"].append( # type: ignore[attr-defined]
TextSpanRegion(
type="text_span",
start_index=citation.start_block_index,
end_index=citation.end_block_index,
)
@@ -10,16 +10,12 @@ from agent_framework import (
ChatMessage,
ChatOptions,
ChatResponseUpdate,
DataContent,
Content,
FinishReason,
FunctionCallContent,
FunctionResultContent,
HostedCodeInterpreterTool,
HostedMCPTool,
HostedWebSearchTool,
Role,
TextContent,
TextReasoningContent,
ai_function,
)
from agent_framework.exceptions import ServiceInitializationError
@@ -170,7 +166,7 @@ def test_prepare_message_for_anthropic_function_call(mock_anthropic_client: Magi
message = ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_123",
name="get_weather",
arguments={"location": "San Francisco"},
@@ -194,9 +190,8 @@ def test_prepare_message_for_anthropic_function_result(mock_anthropic_client: Ma
message = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
Content.from_function_result(
call_id="call_123",
name="get_weather",
result="Sunny, 72°F",
)
],
@@ -219,7 +214,7 @@ def test_prepare_message_for_anthropic_text_reasoning(mock_anthropic_client: Mag
chat_client = create_test_anthropic_client(mock_anthropic_client)
message = ChatMessage(
role=Role.ASSISTANT,
contents=[TextReasoningContent(text="Let me think about this...")],
contents=[Content.from_text_reasoning(text="Let me think about this...")],
)
result = chat_client._prepare_message_for_anthropic(message)
@@ -507,12 +502,12 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None:
assert len(response.messages) == 1
assert response.messages[0].role == Role.ASSISTANT
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], TextContent)
assert response.messages[0].contents[0].type == "text"
assert response.messages[0].contents[0].text == "Hello there!"
assert response.finish_reason == FinishReason.STOP
assert response.usage_details is not None
assert response.usage_details.input_token_count == 10
assert response.usage_details.output_token_count == 5
assert response.usage_details["input_token_count"] == 10
assert response.usage_details["output_token_count"] == 5
def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None:
@@ -536,7 +531,7 @@ def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None
response = chat_client._process_message(mock_message)
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert response.messages[0].contents[0].type == "function_call"
assert response.messages[0].contents[0].call_id == "call_123"
assert response.messages[0].contents[0].name == "get_weather"
assert response.finish_reason == FinishReason.TOOL_CALLS
@@ -550,8 +545,8 @@ def test_parse_usage_from_anthropic_basic(mock_anthropic_client: MagicMock) -> N
result = chat_client._parse_usage_from_anthropic(usage)
assert result is not None
assert result.input_token_count == 10
assert result.output_token_count == 5
assert result["input_token_count"] == 10
assert result["output_token_count"] == 5
def test_parse_usage_from_anthropic_none(mock_anthropic_client: MagicMock) -> None:
@@ -571,7 +566,7 @@ def test_parse_contents_from_anthropic_text(mock_anthropic_client: MagicMock) ->
result = chat_client._parse_contents_from_anthropic(content)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
assert result[0].text == "Hello!"
@@ -590,7 +585,7 @@ def test_parse_contents_from_anthropic_tool_use(mock_anthropic_client: MagicMock
result = chat_client._parse_contents_from_anthropic(content)
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].call_id == "call_123"
assert result[0].name == "get_weather"
@@ -613,7 +608,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a
result = chat_client._parse_contents_from_anthropic([tool_use_content])
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].call_id == "call_123"
assert result[0].name == "get_weather" # Initial event has name
@@ -624,7 +619,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a
result = chat_client._parse_contents_from_anthropic([delta_content_1])
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].call_id == "call_123"
assert result[0].name == "" # Delta events should have empty name
assert result[0].arguments == '{"location":'
@@ -636,7 +631,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a
result = chat_client._parse_contents_from_anthropic([delta_content_2])
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].call_id == "call_123"
assert result[0].name == "" # Still empty name for subsequent deltas
assert result[0].arguments == '"San Francisco"}'
@@ -771,9 +766,7 @@ async def test_anthropic_client_integration_function_calling() -> None:
assert response is not None
# Should contain function call
has_function_call = any(
isinstance(content, FunctionCallContent) for msg in response.messages for content in msg.contents
)
has_function_call = any(content.type == "function_call" for msg in response.messages for content in msg.contents)
assert has_function_call
@@ -872,8 +865,8 @@ async def test_anthropic_client_integration_images() -> None:
ChatMessage(
role=Role.USER,
contents=[
TextContent(text="Describe this image"),
DataContent(media_type="image/jpeg", data=image_bytes),
Content.from_text(text="Describe this image"),
Content.from_data(media_type="image/jpeg", data=image_bytes),
],
),
]