485 lines
16 KiB
Python
485 lines
16 KiB
Python
|
|
"""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,
|
||
|
|
},
|
||
|
|
}
|