Files
AI/참고/instructor-main/instructor/providers/anthropic/utils.py

485 lines
16 KiB
Python
Raw Normal View History

2026-05-12 19:40:31 +09:00
"""Anthropic-specific utilities.
This module contains utilities specific to the Anthropic provider,
including reask functions, response handlers, and message formatting.
"""
from __future__ import annotations
from textwrap import dedent
from typing import Any, TypedDict, Union
from ...mode import Mode
from ...processing.schema import generate_anthropic_schema
class SystemMessage(TypedDict, total=False):
type: str
text: str
cache_control: dict[str, str]
def combine_system_messages(
existing_system: Union[str, list[SystemMessage], None], # noqa: UP007
new_system: Union[str, list[SystemMessage]], # noqa: UP007
) -> Union[str, list[SystemMessage]]: # noqa: UP007
"""
Combine existing and new system messages.
This optimized version uses a more direct approach with fewer branches.
Args:
existing_system: Existing system message(s) or None
new_system: New system message(s) to add
Returns:
Combined system message(s)
"""
# Fast path for None existing_system (avoid unnecessary operations)
if existing_system is None:
return new_system
# Validate input types
if not isinstance(existing_system, (str, list)) or not isinstance(
new_system, (str, list)
):
raise ValueError(
f"System messages must be strings or lists, got {type(existing_system)} and {type(new_system)}"
)
# Use direct type comparison instead of isinstance for better performance
if isinstance(existing_system, str) and isinstance(new_system, str):
# Both are strings, join with newlines
# Avoid creating intermediate strings by joining only once
return f"{existing_system}\n\n{new_system}"
elif isinstance(existing_system, list) and isinstance(new_system, list):
# Both are lists, use list extension in place to avoid creating intermediate lists
# First create a new list to avoid modifying the original
result = list(existing_system)
result.extend(new_system)
return result
elif isinstance(existing_system, str) and isinstance(new_system, list):
# existing is string, new is list
# Create a pre-sized list to avoid resizing
result = [SystemMessage(type="text", text=existing_system)]
result.extend(new_system)
return result
elif isinstance(existing_system, list) and isinstance(new_system, str):
# existing is list, new is string
# Create message once and add to existing
new_message = SystemMessage(type="text", text=new_system)
result = list(existing_system)
result.append(new_message)
return result
# This should never happen due to validation above
return existing_system
def extract_system_messages(messages: list[dict[str, Any]]) -> list[SystemMessage]:
"""
Extract system messages from a list of messages.
This optimized version pre-allocates the result list and
reduces function call overhead.
Args:
messages: List of messages to extract system messages from
Returns:
List of system messages
"""
# Fast path for empty messages
if not messages:
return []
# First count system messages to pre-allocate result list
system_count = sum(1 for m in messages if m.get("role") == "system")
# If no system messages, return empty list
if system_count == 0:
return []
# Helper function to convert a message content to SystemMessage
def convert_message(content: Any) -> SystemMessage:
if isinstance(content, str):
return SystemMessage(type="text", text=content)
elif isinstance(content, dict):
return SystemMessage(**content)
else:
raise ValueError(f"Unsupported content type: {type(content)}")
# Process system messages
result: list[SystemMessage] = []
for message in messages:
if message.get("role") == "system":
content = message.get("content")
# Skip empty content
if not content:
continue
# Handle list or single content
if isinstance(content, list):
# Process each item in the list
for item in content:
if item: # Skip empty items
result.append(convert_message(item))
else:
# Process single content
result.append(convert_message(content))
return result
def reask_anthropic_tools(
kwargs: dict[str, Any],
response: Any,
exception: Exception,
):
"""
Handle reask for Anthropic tools mode when validation fails.
Kwargs modifications:
- Adds: "messages" (tool result messages indicating validation errors)
"""
kwargs = kwargs.copy()
from anthropic.types import Message
# Handle Stream objects which are not Message instances
# This happens when streaming mode is used with retries
if not isinstance(response, Message):
kwargs["messages"].append(
{
"role": "user",
"content": (
f"Validation Error found:\n{exception}\n"
"Recall the function correctly, fix the errors"
),
}
)
return kwargs
assistant_content = []
tool_use_id = None
for content in response.content:
assistant_content.append(content.model_dump(exclude_none=True)) # type: ignore
if content.type == "tool_use":
tool_use_id = content.id
reask_msgs = [{"role": "assistant", "content": assistant_content}] # type: ignore
if tool_use_id is not None:
reask_msgs.append( # type: ignore
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": f"Validation Error found:\n{exception}\nRecall the function correctly, fix the errors",
"is_error": True,
}
],
}
)
else:
reask_msgs.append( # type: ignore
{
"role": "user",
"content": f"Validation Error due to no tool invocation:\n{exception}\nRecall the function correctly, fix the errors",
}
)
kwargs["messages"].extend(reask_msgs)
return kwargs
def reask_anthropic_json(
kwargs: dict[str, Any],
response: Any,
exception: Exception,
):
"""
Handle reask for Anthropic JSON mode when validation fails.
Kwargs modifications:
- Adds: "messages" (user message requesting JSON correction)
"""
kwargs = kwargs.copy()
from anthropic.types import Message
# Handle Stream objects which are not Message instances
# This happens when streaming mode is used with retries
if not isinstance(response, Message):
kwargs["messages"].append(
{
"role": "user",
"content": (
f"Validation Errors found:\n{exception}\n"
"Recall the function correctly, fix the errors"
),
}
)
return kwargs
# Filter for text blocks to handle ThinkingBlock and other non-text content
text_blocks = [c for c in response.content if c.type == "text"]
if not text_blocks:
# Fallback if no text blocks found
text_content = "No text content found in response"
else:
# Use the last text block, similar to function_calls.py:396-397
text_content = text_blocks[-1].text
reask_msg = {
"role": "user",
"content": f"""Validation Errors found:\n{exception}\nRecall the function correctly, fix the errors found in the following attempt:\n{text_content}""",
}
kwargs["messages"].append(reask_msg)
return kwargs
def handle_anthropic_message_conversion(new_kwargs: dict[str, Any]) -> dict[str, Any]:
"""
Handle message conversion for Anthropic modes when response_model is None.
Kwargs modifications:
- Modifies: "messages" (removes system messages)
- Adds/Modifies: "system" (if system messages found in messages)
"""
messages = new_kwargs.get("messages", [])
# Handle Anthropic style messages
new_kwargs["messages"] = [m for m in messages if m["role"] != "system"]
if "system" not in new_kwargs:
system_messages = extract_system_messages(messages)
if system_messages:
new_kwargs["system"] = system_messages
return new_kwargs
def handle_anthropic_tools(
response_model: type[Any] | None, new_kwargs: dict[str, Any]
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle Anthropic tools mode.
When response_model is None:
- Extracts system messages from the messages list and moves them to the 'system' parameter
- Filters out system messages from the messages list
- No tools are configured
- Allows for unstructured responses from Claude
When response_model is provided:
- Generates Anthropic tool schema from the response model
- Sets up forced tool use with the specific tool name
- Extracts and combines system messages
- Filters system messages from the messages list
Kwargs modifications:
- Modifies: "messages" (removes system messages)
- Adds/Modifies: "system" (combines existing with extracted system messages)
- Adds: "tools" (list with tool schema) - only when response_model provided
- Adds: "tool_choice" (forced tool use) - only when response_model provided
"""
if response_model is None:
# Just handle message conversion
new_kwargs = handle_anthropic_message_conversion(new_kwargs)
return None, new_kwargs
tool_descriptions = generate_anthropic_schema(response_model)
new_kwargs["tools"] = [tool_descriptions]
new_kwargs["tool_choice"] = {
"type": "tool",
"name": response_model.__name__,
}
system_messages = extract_system_messages(new_kwargs.get("messages", []))
if system_messages:
new_kwargs["system"] = combine_system_messages(
new_kwargs.get("system"), system_messages
)
new_kwargs["messages"] = [
m for m in new_kwargs.get("messages", []) if m["role"] != "system"
]
return response_model, new_kwargs
def handle_anthropic_reasoning_tools(
response_model: type[Any] | None, new_kwargs: dict[str, Any]
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle Anthropic reasoning tools mode.
This mode is similar to regular tools mode but with reasoning enabled:
- Uses "auto" tool choice instead of forced tool use
- Adds a system message encouraging tool use only when relevant
- Allows Claude to reason about whether to use tools
When response_model is None:
- Performs the same message conversion as handle_anthropic_tools
- No tools are configured
When response_model is provided:
- Sets up tools as in regular tools mode
- Changes tool_choice to "auto" to allow reasoning
- Adds system message to guide tool usage
Kwargs modifications:
- All modifications from handle_anthropic_tools, plus:
- Modifies: "tool_choice" (changes to {"type": "auto"}) - only when response_model provided
- Modifies: "system" (adds implicit forced tool message)
"""
# https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview#forcing-tool-use
response_model, new_kwargs = handle_anthropic_tools(response_model, new_kwargs)
if response_model is None:
# Just handle message conversion - already done by handle_anthropic_tools
return None, new_kwargs
# https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview#forcing-tool-use
# Reasoning does not allow forced tool use
new_kwargs["tool_choice"] = {"type": "auto"}
# But add a message recommending only to use the tools if they are relevant
implict_forced_tool_message = dedent(
f"""
Return only the tool call and no additional text.
"""
)
new_kwargs["system"] = combine_system_messages(
new_kwargs.get("system"),
[{"type": "text", "text": implict_forced_tool_message}],
)
return response_model, new_kwargs
def handle_anthropic_json(
response_model: type[Any] | None, new_kwargs: dict[str, Any]
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle Anthropic JSON mode.
This mode instructs Claude to return JSON responses:
- System messages are extracted and combined
- A JSON schema message is added to guide the response format
When response_model is None:
- Extracts and moves system messages to the 'system' parameter
- Filters system messages from the messages list
- No JSON schema is added
When response_model is provided:
- Performs system message handling as above
- Adds a system message with the JSON schema
- Instructs Claude to return an instance matching the schema
Kwargs modifications:
- Modifies: "messages" (removes system messages)
- Adds/Modifies: "system" (combines existing with extracted system messages)
- Modifies: "system" (adds JSON schema message) - only when response_model provided
"""
import json
system_messages = extract_system_messages(new_kwargs.get("messages", []))
if system_messages:
new_kwargs["system"] = combine_system_messages(
new_kwargs.get("system"), system_messages
)
new_kwargs["messages"] = [
m for m in new_kwargs.get("messages", []) if m["role"] != "system"
]
if response_model is None:
# Just handle message conversion - already done above
return None, new_kwargs
json_schema_message = dedent(
f"""
As a genius expert, your task is to understand the content and provide
the parsed objects in json that match the following json_schema:\n
{json.dumps(response_model.model_json_schema(), indent=2, ensure_ascii=False)}
Make sure to return an instance of the JSON, not the schema itself
"""
)
new_kwargs["system"] = combine_system_messages(
new_kwargs.get("system"),
[{"type": "text", "text": json_schema_message}],
)
return response_model, new_kwargs
def handle_anthropic_parallel_tools(
response_model: type[Any], new_kwargs: dict[str, Any]
) -> tuple[Any, dict[str, Any]]:
"""
Handle Anthropic parallel tools mode.
Kwargs modifications:
- Adds: "tools" (multiple function schemas from parallel model)
- Adds: "tool_choice" ("auto" to allow model to choose which tools to call)
- Modifies: "system" (moves system messages into system parameter)
- Removes: "system" messages from "messages" list
- Validates: stream=False
"""
from ...dsl.parallel import (
AnthropicParallelModel,
handle_anthropic_parallel_model,
)
from ...core.exceptions import ConfigurationError
if new_kwargs.get("stream", False):
raise ConfigurationError(
"stream=True is not supported when using ANTHROPIC_PARALLEL_TOOLS mode"
)
new_kwargs["tools"] = handle_anthropic_parallel_model(response_model)
new_kwargs["tool_choice"] = {"type": "auto"}
system_messages = extract_system_messages(new_kwargs.get("messages", []))
if system_messages:
new_kwargs["system"] = combine_system_messages(
new_kwargs.get("system"), system_messages
)
new_kwargs["messages"] = [
m for m in new_kwargs.get("messages", []) if m["role"] != "system"
]
return AnthropicParallelModel(typehint=response_model), new_kwargs
# Handler registry for Anthropic
ANTHROPIC_HANDLERS = {
Mode.ANTHROPIC_TOOLS: {
"reask": reask_anthropic_tools,
"response": handle_anthropic_tools,
},
Mode.ANTHROPIC_JSON: {
"reask": reask_anthropic_json,
"response": handle_anthropic_json,
},
Mode.ANTHROPIC_REASONING_TOOLS: {
"reask": reask_anthropic_tools,
"response": handle_anthropic_reasoning_tools,
},
Mode.ANTHROPIC_PARALLEL_TOOLS: {
"reask": reask_anthropic_tools,
"response": handle_anthropic_parallel_tools,
},
}