chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
"""Tests for provider-facing tool schema aliases."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import importlib.util
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.exceptions import InvalidSchemaError
|
||||
from composio.utils.shared import (
|
||||
alias_tool_input_schema,
|
||||
json_schema_to_model,
|
||||
substitute_reserved_python_keywords,
|
||||
)
|
||||
|
||||
|
||||
PYTHON_ROOT = Path(__file__).resolve().parents[1]
|
||||
ANTHROPIC_PROPERTY_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$")
|
||||
|
||||
|
||||
def _load_module(monkeypatch, module_name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_alias_tool_input_schema_restores_nested_aliases_without_mutating_schema():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {"type": "string"},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {"class": {"type": "string"}},
|
||||
"required": ["class"],
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"for": {"type": "string"}},
|
||||
"required": ["for"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["from", "payload"],
|
||||
}
|
||||
original = copy.deepcopy(schema)
|
||||
|
||||
aliases = alias_tool_input_schema(schema)
|
||||
|
||||
assert schema == original
|
||||
assert list(aliases.schema["properties"]) == ["from_rs", "payload", "items"]
|
||||
assert aliases.schema["required"] == ["from_rs", "payload"]
|
||||
assert aliases.schema["properties"]["payload"]["required"] == ["class_rs"]
|
||||
assert aliases.schema["properties"]["items"]["items"]["required"] == ["for_rs"]
|
||||
|
||||
arguments = {
|
||||
"from_rs": "sender@example.com",
|
||||
"payload": {"class_rs": "primary"},
|
||||
"items": [{"for_rs": "recipient@example.com"}],
|
||||
}
|
||||
assert aliases.restore_arguments(arguments) == {
|
||||
"from": "sender@example.com",
|
||||
"payload": {"class": "primary"},
|
||||
"items": [{"for": "recipient@example.com"}],
|
||||
}
|
||||
|
||||
|
||||
def test_alias_tool_input_schema_rejects_duplicate_aliases():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {"type": "string"},
|
||||
"from_rs": {"type": "string"},
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(InvalidSchemaError, match="duplicate Python parameter alias"):
|
||||
alias_tool_input_schema(schema)
|
||||
|
||||
|
||||
def test_legacy_keyword_helpers_use_tool_schema_aliases():
|
||||
long_name = "x" * 80
|
||||
schema = {
|
||||
"title": "ODataParams",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$top": {"type": "integer"},
|
||||
"@microsoft.graph.conflictBehavior": {"type": "string"},
|
||||
long_name: {"type": "string"},
|
||||
},
|
||||
"required": ["$top", long_name],
|
||||
}
|
||||
|
||||
aliased_schema, aliases = substitute_reserved_python_keywords(schema)
|
||||
|
||||
aliased_names = list(aliased_schema["properties"])
|
||||
assert aliased_names[0] == "param_top"
|
||||
assert aliased_names[1] == "param_microsoft_graph_conflictBehavior"
|
||||
assert len(aliased_names[2]) == 64
|
||||
assert all(ANTHROPIC_PROPERTY_RE.fullmatch(name) for name in aliased_names)
|
||||
assert aliased_schema["required"] == ["param_top", aliased_names[2]]
|
||||
assert aliases["param_top"] == "$top"
|
||||
assert aliases["param_microsoft_graph_conflictBehavior"] == (
|
||||
"@microsoft.graph.conflictBehavior"
|
||||
)
|
||||
assert aliases[aliased_names[2]] == long_name
|
||||
model = json_schema_to_model(aliased_schema)
|
||||
assert "param_top" in model.model_fields
|
||||
|
||||
|
||||
def test_alias_tool_input_schema_dereferences_refs_before_aliasing():
|
||||
schema = {
|
||||
"$ref": "#/$defs/SearchParams",
|
||||
"$defs": {
|
||||
"SearchParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {"$ref": "#/$defs/ODataFilter"},
|
||||
},
|
||||
"required": ["filter"],
|
||||
},
|
||||
"ODataFilter": {
|
||||
"type": "object",
|
||||
"properties": {"$top": {"type": "integer"}},
|
||||
"required": ["$top"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
aliases = alias_tool_input_schema(schema)
|
||||
|
||||
assert "$defs" not in aliases.schema
|
||||
assert aliases.schema["required"] == ["filter"]
|
||||
filter_schema = aliases.schema["properties"]["filter"]
|
||||
assert list(filter_schema["properties"]) == ["param_top"]
|
||||
assert filter_schema["required"] == ["param_top"]
|
||||
assert aliases.restore_arguments({"filter": {"param_top": 10}}) == {
|
||||
"filter": {"$top": 10}
|
||||
}
|
||||
|
||||
|
||||
def test_gemini_manual_response_restores_provider_visible_aliases(monkeypatch):
|
||||
google_module = types.ModuleType("google")
|
||||
genai_module = types.ModuleType("google.genai")
|
||||
genai_types_module = types.ModuleType("google.genai.types")
|
||||
|
||||
class FunctionResponse:
|
||||
def __init__(self, name, response):
|
||||
self.name = name
|
||||
self.response = response
|
||||
|
||||
class Part:
|
||||
def __init__(self, function_response):
|
||||
self.function_response = function_response
|
||||
|
||||
genai_types_module.FunctionResponse = FunctionResponse
|
||||
genai_types_module.Part = Part
|
||||
genai_module.types = genai_types_module
|
||||
google_module.genai = genai_module
|
||||
monkeypatch.setitem(sys.modules, "google", google_module)
|
||||
monkeypatch.setitem(sys.modules, "google.genai", genai_module)
|
||||
monkeypatch.setitem(sys.modules, "google.genai.types", genai_types_module)
|
||||
|
||||
provider_module = _load_module(
|
||||
monkeypatch,
|
||||
"test_composio_gemini_provider",
|
||||
PYTHON_ROOT / "providers/gemini/composio_gemini/provider.py",
|
||||
)
|
||||
provider = provider_module.GeminiProvider()
|
||||
execute_tool = Mock(return_value={"successful": True, "data": {"ok": True}})
|
||||
tool = SimpleNamespace(
|
||||
slug="TOOL_WITH_RESERVED",
|
||||
description="Tool with reserved parameters",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {"for": {"type": "string"}},
|
||||
"required": ["for"],
|
||||
},
|
||||
)
|
||||
provider.wrap_tools([tool], execute_tool)
|
||||
|
||||
response = SimpleNamespace(
|
||||
candidates=[
|
||||
SimpleNamespace(
|
||||
content=SimpleNamespace(
|
||||
parts=[
|
||||
SimpleNamespace(
|
||||
function_call=SimpleNamespace(
|
||||
name="TOOL_WITH_RESERVED",
|
||||
args={"for_rs": "recipient@example.com"},
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
function_responses, executed = provider.handle_response(response)
|
||||
|
||||
assert executed is True
|
||||
assert function_responses[0].function_response.name == "TOOL_WITH_RESERVED"
|
||||
execute_tool.assert_called_once_with(
|
||||
slug="TOOL_WITH_RESERVED", arguments={"for": "recipient@example.com"}
|
||||
)
|
||||
|
||||
|
||||
def test_google_adk_wrap_tool_aliases_signature_and_restores_arguments(monkeypatch):
|
||||
google_module = types.ModuleType("google")
|
||||
adk_module = types.ModuleType("google.adk")
|
||||
tools_module = types.ModuleType("google.adk.tools")
|
||||
|
||||
class FunctionTool:
|
||||
def __init__(self, func):
|
||||
self.func = func
|
||||
|
||||
tools_module.FunctionTool = FunctionTool
|
||||
adk_module.tools = tools_module
|
||||
google_module.adk = adk_module
|
||||
monkeypatch.setitem(sys.modules, "google", google_module)
|
||||
monkeypatch.setitem(sys.modules, "google.adk", adk_module)
|
||||
monkeypatch.setitem(sys.modules, "google.adk.tools", tools_module)
|
||||
|
||||
provider_module = _load_module(
|
||||
monkeypatch,
|
||||
"test_composio_google_adk_provider",
|
||||
PYTHON_ROOT / "providers/google_adk/composio_google_adk/provider.py",
|
||||
)
|
||||
provider = provider_module.GoogleAdkProvider()
|
||||
execute_tool = Mock(return_value={"successful": True, "data": {"ok": True}})
|
||||
tool = SimpleNamespace(
|
||||
slug="TOOL_WITH_RESERVED",
|
||||
description="Tool with reserved parameters",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {"type": "string", "description": "Sender"},
|
||||
"limit": {"type": "integer", "description": "Limit"},
|
||||
},
|
||||
"required": ["from"],
|
||||
},
|
||||
)
|
||||
|
||||
wrapped = provider.wrap_tool(tool, execute_tool)
|
||||
|
||||
assert list(inspect.signature(wrapped.func).parameters) == ["from_rs", "limit"]
|
||||
assert "from_rs: Sender" in (wrapped.func.__doc__ or "")
|
||||
wrapped.func(from_rs="sender@example.com", limit=10)
|
||||
execute_tool.assert_called_once_with(
|
||||
slug="TOOL_WITH_RESERVED",
|
||||
arguments={"from": "sender@example.com", "limit": 10},
|
||||
)
|
||||
|
||||
|
||||
def test_anthropic_wrap_tool_aliases_schema_and_restores_arguments(monkeypatch):
|
||||
anthropic_module = types.ModuleType("anthropic")
|
||||
types_module = types.ModuleType("anthropic.types")
|
||||
beta_module = types.ModuleType("anthropic.types.beta")
|
||||
beta_tool_use_module = types.ModuleType("anthropic.types.beta.beta_tool_use_block")
|
||||
message_module = types.ModuleType("anthropic.types.message")
|
||||
tool_param_module = types.ModuleType("anthropic.types.tool_param")
|
||||
tool_use_module = types.ModuleType("anthropic.types.tool_use_block")
|
||||
|
||||
class BetaToolUseBlock:
|
||||
pass
|
||||
|
||||
class Message:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
class ToolUseBlock:
|
||||
pass
|
||||
|
||||
beta_tool_use_module.BetaToolUseBlock = BetaToolUseBlock
|
||||
message_module.Message = Message
|
||||
tool_param_module.ToolParam = dict
|
||||
tool_use_module.ToolUseBlock = ToolUseBlock
|
||||
monkeypatch.setitem(sys.modules, "anthropic", anthropic_module)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types", types_module)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types.beta", beta_module)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"anthropic.types.beta.beta_tool_use_block",
|
||||
beta_tool_use_module,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types.message", message_module)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types.tool_param", tool_param_module)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types.tool_use_block", tool_use_module)
|
||||
|
||||
provider_module = _load_module(
|
||||
monkeypatch,
|
||||
"test_composio_anthropic_provider",
|
||||
PYTHON_ROOT / "providers/anthropic/composio_anthropic/provider.py",
|
||||
)
|
||||
provider = provider_module.AnthropicProvider()
|
||||
provider.execute_tool = Mock(return_value={"successful": True})
|
||||
long_name = "x" * 80
|
||||
tool = SimpleNamespace(
|
||||
slug="TOOL_WITH_ODATA",
|
||||
description="Tool with OData parameters",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$top": {"type": "integer"},
|
||||
"@microsoft.graph.conflictBehavior": {"type": "string"},
|
||||
long_name: {"type": "string"},
|
||||
},
|
||||
"required": ["$top", long_name],
|
||||
},
|
||||
)
|
||||
|
||||
wrapped = provider.wrap_tool(tool)
|
||||
|
||||
aliased_names = list(wrapped["input_schema"]["properties"])
|
||||
assert aliased_names[0] == "param_top"
|
||||
assert aliased_names[1] == "param_microsoft_graph_conflictBehavior"
|
||||
assert len(aliased_names[2]) == 64
|
||||
assert all(ANTHROPIC_PROPERTY_RE.fullmatch(name) for name in aliased_names)
|
||||
|
||||
tool_call = SimpleNamespace(
|
||||
name="TOOL_WITH_ODATA",
|
||||
input={
|
||||
"param_top": 10,
|
||||
"param_microsoft_graph_conflictBehavior": "rename",
|
||||
aliased_names[2]: "value",
|
||||
},
|
||||
)
|
||||
provider.execute_tool_call(user_id="user", tool_call=tool_call)
|
||||
|
||||
provider.execute_tool.assert_called_once_with(
|
||||
slug="TOOL_WITH_ODATA",
|
||||
arguments={
|
||||
"$top": 10,
|
||||
"@microsoft.graph.conflictBehavior": "rename",
|
||||
long_name: "value",
|
||||
},
|
||||
modifiers=None,
|
||||
user_id="user",
|
||||
)
|
||||
|
||||
|
||||
def test_claude_agent_sdk_wrap_tool_aliases_schema_and_restores_arguments(monkeypatch):
|
||||
claude_agent_sdk_module = types.ModuleType("claude_agent_sdk")
|
||||
|
||||
def sdk_tool(name, description, input_schema):
|
||||
def decorator(fn):
|
||||
fn._tool_name = name
|
||||
fn._tool_description = description
|
||||
fn._input_schema = input_schema
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
claude_agent_sdk_module.McpSdkServerConfig = dict
|
||||
claude_agent_sdk_module.SdkMcpTool = object
|
||||
claude_agent_sdk_module.create_sdk_mcp_server = Mock()
|
||||
claude_agent_sdk_module.tool = sdk_tool
|
||||
monkeypatch.setitem(sys.modules, "claude_agent_sdk", claude_agent_sdk_module)
|
||||
|
||||
provider_module = _load_module(
|
||||
monkeypatch,
|
||||
"test_composio_claude_agent_sdk_provider",
|
||||
PYTHON_ROOT
|
||||
/ "providers/claude_agent_sdk/composio_claude_agent_sdk/provider.py",
|
||||
)
|
||||
provider = provider_module.ClaudeAgentSDKProvider()
|
||||
execute_tool = Mock(return_value={"successful": True})
|
||||
tool = SimpleNamespace(
|
||||
slug="TOOL_WITH_ODATA",
|
||||
description="Tool with OData parameters",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {"$top": {"type": "integer"}},
|
||||
"required": ["$top"],
|
||||
},
|
||||
)
|
||||
|
||||
wrapped = provider.wrap_tool(tool, execute_tool)
|
||||
|
||||
assert list(wrapped._input_schema["properties"]) == ["param_top"]
|
||||
assert wrapped._input_schema["required"] == ["param_top"]
|
||||
result = asyncio.run(wrapped({"param_top": 5}))
|
||||
|
||||
assert result["content"][0]["type"] == "text"
|
||||
execute_tool.assert_called_once_with("TOOL_WITH_ODATA", {"$top": 5})
|
||||
Reference in New Issue
Block a user