Rebase onto upstream (a4d95fd)
#12
@@ -199,22 +199,32 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
|
||||
def _convert_tools_to_anthropic(
|
||||
self,
|
||||
tools: list[dict[str, Any]] | None
|
||||
tools: list[dict[str, Any]] | list[Any] | None
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Convert tools to Anthropic API format.
|
||||
|
||||
Supports both function tools (custom) and native tools (Anthropic).
|
||||
Function tools are converted to Anthropic format.
|
||||
Native tools are passed through unchanged.
|
||||
Tool objects (with to_params/to_schema methods) are converted to dicts.
|
||||
"""
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
anthropic_tools = []
|
||||
for tool in tools:
|
||||
if tool.get("type") == "function":
|
||||
# Convert tool objects to dicts first
|
||||
if hasattr(tool, 'to_params'): # Native Anthropic tool
|
||||
tool_dict = tool.to_params()
|
||||
elif hasattr(tool, 'to_schema'): # Function tool
|
||||
tool_dict = tool.to_schema()
|
||||
else:
|
||||
tool_dict = tool # Already a dict
|
||||
|
||||
# Now process the dict
|
||||
if tool_dict.get("type") == "function":
|
||||
# Convert function tool format
|
||||
func = tool["function"]
|
||||
func = tool_dict["function"]
|
||||
anthropic_tools.append({
|
||||
"name": func["name"],
|
||||
"description": func.get("description", ""),
|
||||
@@ -223,7 +233,7 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
else:
|
||||
# Pass through native tool format as-is
|
||||
# (bash_20250124, text_editor_20250728, computer_20251124, etc.)
|
||||
anthropic_tools.append(tool)
|
||||
anthropic_tools.append(tool_dict)
|
||||
|
||||
return anthropic_tools if anthropic_tools else None
|
||||
|
||||
@@ -237,6 +247,7 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
thinking_budget_override: int | None = None,
|
||||
context_management: dict[str, Any] | None = None,
|
||||
beta_flags: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Make request to Anthropic API."""
|
||||
client = await self._get_client()
|
||||
@@ -294,9 +305,14 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
edit_types or "none",
|
||||
)
|
||||
|
||||
# Build headers with beta flags if provided
|
||||
headers = self._get_headers()
|
||||
if beta_flags:
|
||||
headers["anthropic-beta"] = ",".join(sorted(beta_flags))
|
||||
|
||||
response = await client.post(
|
||||
self._get_api_url(),
|
||||
headers=self._get_headers(),
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
@@ -342,7 +358,7 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tools: list[dict[str, Any]] | list[Any] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
@@ -360,6 +376,15 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
model = self._normalize_model(model)
|
||||
|
||||
system, prepared_messages = self._prepare_messages(messages)
|
||||
|
||||
# Collect beta flags from native tools BEFORE conversion
|
||||
beta_flags: set[str] = set()
|
||||
if tools:
|
||||
for tool in tools:
|
||||
if hasattr(tool, 'beta_flag') and tool.beta_flag:
|
||||
beta_flags.add(tool.beta_flag)
|
||||
|
||||
# Convert tools to API format
|
||||
anthropic_tools = self._convert_tools_to_anthropic(tools)
|
||||
|
||||
# Per-call thinking override (None = use instance default)
|
||||
@@ -375,6 +400,7 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
tools=anthropic_tools,
|
||||
thinking_budget_override=effective_thinking,
|
||||
context_management=context_management,
|
||||
beta_flags=beta_flags,
|
||||
)
|
||||
return self._parse_response(response)
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for beta flag collection from native tools."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_beta_flags_collected_from_tools():
|
||||
"""Test that beta flags are extracted from tool objects."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
# Mock tool objects with beta_flag attribute and to_params method
|
||||
class MockTool:
|
||||
def __init__(self, beta_flag):
|
||||
self.beta_flag = beta_flag
|
||||
|
||||
def to_params(self):
|
||||
return {"type": "bash_20250124", "name": "bash"}
|
||||
|
||||
tools_with_flags = [
|
||||
MockTool("computer-use-2025-11-24"),
|
||||
MockTool("computer-use-2025-11-24"), # Duplicate should be deduplicated
|
||||
]
|
||||
|
||||
# We need to test this via the actual API call flow
|
||||
# Mock httpx client
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "test"}],
|
||||
"model": "claude-opus-4",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 10}
|
||||
}
|
||||
|
||||
with patch.object(provider, '_client') as mock_client:
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Call with messages and tools
|
||||
await provider.chat(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
model="claude-opus-4",
|
||||
max_tokens=100,
|
||||
tools=tools_with_flags
|
||||
)
|
||||
|
||||
# Check that beta flag was added to headers
|
||||
call_args = mock_client.post.call_args
|
||||
headers = call_args[1]["headers"]
|
||||
assert "anthropic-beta" in headers
|
||||
assert headers["anthropic-beta"] == "computer-use-2025-11-24"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_beta_flags_joined():
|
||||
"""Test that multiple unique beta flags are joined with commas."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
class MockTool:
|
||||
def __init__(self, beta_flag):
|
||||
self.beta_flag = beta_flag
|
||||
|
||||
def to_params(self):
|
||||
return {"type": "bash_20250124", "name": "bash"}
|
||||
|
||||
tools_with_flags = [
|
||||
MockTool("flag-a"),
|
||||
MockTool("flag-b"),
|
||||
]
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "test"}],
|
||||
"model": "claude-opus-4",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 10}
|
||||
}
|
||||
|
||||
with patch.object(provider, '_client') as mock_client:
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
await provider.chat(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
model="claude-opus-4",
|
||||
max_tokens=100,
|
||||
tools=tools_with_flags
|
||||
)
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
headers = call_args[1]["headers"]
|
||||
assert "anthropic-beta" in headers
|
||||
# Should be sorted and joined
|
||||
assert headers["anthropic-beta"] in ["flag-a,flag-b", "flag-b,flag-a"]
|
||||
Reference in New Issue
Block a user