utcp-http 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- utcp_http-1.0.0/PKG-INFO +30 -0
- utcp_http-1.0.0/pyproject.toml +48 -0
- utcp_http-1.0.0/setup.cfg +4 -0
- utcp_http-1.0.0/src/utcp_http/__init__.py +39 -0
- utcp_http-1.0.0/src/utcp_http/http_call_template.py +49 -0
- utcp_http-1.0.0/src/utcp_http/http_communication_protocol.py +409 -0
- utcp_http-1.0.0/src/utcp_http/openapi_converter.py +445 -0
- utcp_http-1.0.0/src/utcp_http/sse_call_template.py +50 -0
- utcp_http-1.0.0/src/utcp_http/sse_communication_protocol.py +342 -0
- utcp_http-1.0.0/src/utcp_http/streamable_http_call_template.py +52 -0
- utcp_http-1.0.0/src/utcp_http/streamable_http_communication_protocol.py +382 -0
- utcp_http-1.0.0/src/utcp_http.egg-info/PKG-INFO +30 -0
- utcp_http-1.0.0/src/utcp_http.egg-info/SOURCES.txt +20 -0
- utcp_http-1.0.0/src/utcp_http.egg-info/dependency_links.txt +1 -0
- utcp_http-1.0.0/src/utcp_http.egg-info/entry_points.txt +2 -0
- utcp_http-1.0.0/src/utcp_http.egg-info/requires.txt +16 -0
- utcp_http-1.0.0/src/utcp_http.egg-info/top_level.txt +1 -0
- utcp_http-1.0.0/tests/test_http_communication_protocol.py +696 -0
- utcp_http-1.0.0/tests/test_openapi_converter.py +34 -0
- utcp_http-1.0.0/tests/test_openapi_converter_auth.py +171 -0
- utcp_http-1.0.0/tests/test_sse_communication_protocol.py +400 -0
- utcp_http-1.0.0/tests/test_streamable_http_communication_protocol.py +343 -0
utcp_http-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utcp-http
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Universal Tool Calling Protocol (UTCP) client library for Python
|
|
5
|
+
Author: UTCP Contributors
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
|
+
Project-URL: Homepage, https://utcp.io
|
|
8
|
+
Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
|
|
9
|
+
Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: authlib>=1.0
|
|
18
|
+
Requires-Dist: aiohttp>=3.8
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Requires-Dist: utcp>=1.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-aiohttp; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
27
|
+
Requires-Dist: coverage; extra == "dev"
|
|
28
|
+
Requires-Dist: fastapi; extra == "dev"
|
|
29
|
+
Requires-Dist: uvicorn; extra == "dev"
|
|
30
|
+
Requires-Dist: twine; extra == "dev"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "utcp-http"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "UTCP Contributors" },
|
|
10
|
+
]
|
|
11
|
+
description = "Universal Tool Calling Protocol (UTCP) client library for Python"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pydantic>=2.0",
|
|
16
|
+
"authlib>=1.0",
|
|
17
|
+
"aiohttp>=3.8",
|
|
18
|
+
"pyyaml>=6.0",
|
|
19
|
+
"utcp>=1.0"
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
]
|
|
27
|
+
license = "MPL-2.0"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = [
|
|
31
|
+
"build",
|
|
32
|
+
"pytest",
|
|
33
|
+
"pytest-asyncio",
|
|
34
|
+
"pytest-aiohttp",
|
|
35
|
+
"pytest-cov",
|
|
36
|
+
"coverage",
|
|
37
|
+
"fastapi",
|
|
38
|
+
"uvicorn",
|
|
39
|
+
"twine",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://utcp.io"
|
|
44
|
+
Source = "https://github.com/universal-tool-calling-protocol/python-utcp"
|
|
45
|
+
Issues = "https://github.com/universal-tool-calling-protocol/python-utcp/issues"
|
|
46
|
+
|
|
47
|
+
[project.entry-points."utcp.plugins"]
|
|
48
|
+
http = "utcp_http:register"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""HTTP Communication Protocol plugin for UTCP.
|
|
2
|
+
|
|
3
|
+
This plugin provides HTTP-based communication protocols including:
|
|
4
|
+
- Standard HTTP requests
|
|
5
|
+
- Server-Sent Events (SSE)
|
|
6
|
+
- Streamable HTTP with chunked transfer encoding
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from utcp.plugins.discovery import register_communication_protocol, register_call_template
|
|
10
|
+
from utcp_http.http_communication_protocol import HttpCommunicationProtocol
|
|
11
|
+
from utcp_http.sse_communication_protocol import SseCommunicationProtocol
|
|
12
|
+
from utcp_http.streamable_http_communication_protocol import StreamableHttpCommunicationProtocol
|
|
13
|
+
from utcp_http.http_call_template import HttpCallTemplate, HttpCallTemplateSerializer
|
|
14
|
+
from utcp_http.sse_call_template import SseCallTemplate, SSECallTemplateSerializer
|
|
15
|
+
from utcp_http.streamable_http_call_template import StreamableHttpCallTemplate, StreamableHttpCallTemplateSerializer
|
|
16
|
+
|
|
17
|
+
def register():
|
|
18
|
+
# Register HTTP communication protocols
|
|
19
|
+
register_communication_protocol("http", HttpCommunicationProtocol())
|
|
20
|
+
register_communication_protocol("sse", SseCommunicationProtocol())
|
|
21
|
+
register_communication_protocol("streamable_http", StreamableHttpCommunicationProtocol())
|
|
22
|
+
|
|
23
|
+
# Register call template serializers
|
|
24
|
+
register_call_template("http", HttpCallTemplateSerializer())
|
|
25
|
+
register_call_template("sse", SSECallTemplateSerializer())
|
|
26
|
+
register_call_template("streamable_http", StreamableHttpCallTemplateSerializer())
|
|
27
|
+
|
|
28
|
+
# Export public API
|
|
29
|
+
__all__ = [
|
|
30
|
+
"HttpCommunicationProtocol",
|
|
31
|
+
"SseCommunicationProtocol",
|
|
32
|
+
"StreamableHttpCommunicationProtocol",
|
|
33
|
+
"HttpCallTemplate",
|
|
34
|
+
"SseCallTemplate",
|
|
35
|
+
"StreamableHttpCallTemplate",
|
|
36
|
+
"HttpCallTemplateSerializer",
|
|
37
|
+
"SSECallTemplateSerializer",
|
|
38
|
+
"StreamableHttpCallTemplateSerializer",
|
|
39
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from utcp.data.call_template import CallTemplate, CallTemplateSerializer
|
|
2
|
+
from utcp.data.auth import Auth
|
|
3
|
+
from utcp.interfaces.serializer import Serializer
|
|
4
|
+
from utcp.exceptions import UtcpSerializerValidationError
|
|
5
|
+
import traceback
|
|
6
|
+
from typing import Optional, Dict, List, Literal
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
class HttpCallTemplate(CallTemplate):
|
|
10
|
+
"""Provider configuration for HTTP-based tools.
|
|
11
|
+
|
|
12
|
+
Supports RESTful HTTP/HTTPS APIs with various HTTP methods, authentication,
|
|
13
|
+
custom headers, and flexible request/response handling. Supports URL path
|
|
14
|
+
parameters using {parameter_name} syntax. All tool arguments not mapped to
|
|
15
|
+
URL body, headers or query pattern parameters are passed as query parameters using '?arg_name={arg_value}'.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
call_template_type: Always "http" for HTTP providers.
|
|
19
|
+
http_method: The HTTP method to use for requests.
|
|
20
|
+
url: The base URL for the HTTP endpoint. Supports path parameters like
|
|
21
|
+
"https://api.example.com/users/{user_id}/posts/{post_id}".
|
|
22
|
+
content_type: The Content-Type header for requests.
|
|
23
|
+
auth: Optional authentication configuration.
|
|
24
|
+
headers: Optional static headers to include in all requests.
|
|
25
|
+
body_field: Name of the tool argument to map to the HTTP request body.
|
|
26
|
+
header_fields: List of tool argument names to map to HTTP request headers.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
call_template_type: Literal["http"] = "http"
|
|
30
|
+
http_method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "GET"
|
|
31
|
+
url: str
|
|
32
|
+
content_type: str = Field(default="application/json")
|
|
33
|
+
auth: Optional[Auth] = None
|
|
34
|
+
headers: Optional[Dict[str, str]] = None
|
|
35
|
+
body_field: Optional[str] = Field(default="body", description="The name of the single input field to be sent as the request body.")
|
|
36
|
+
header_fields: Optional[List[str]] = Field(default=None, description="List of input fields to be sent as request headers.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HttpCallTemplateSerializer(Serializer[HttpCallTemplate]):
|
|
40
|
+
"""Serializer for HttpCallTemplate."""
|
|
41
|
+
|
|
42
|
+
def to_dict(self, obj: HttpCallTemplate) -> dict:
|
|
43
|
+
return obj.model_dump()
|
|
44
|
+
|
|
45
|
+
def validate_dict(self, obj: dict) -> HttpCallTemplate:
|
|
46
|
+
try:
|
|
47
|
+
return HttpCallTemplate.model_validate(obj)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
raise UtcpSerializerValidationError("Invalid HttpCallTemplate: " + traceback.format_exc()) from e
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""HTTP communication protocol implementation for UTCP client.
|
|
2
|
+
|
|
3
|
+
This module provides the HTTP communication protocol implementation that handles communication
|
|
4
|
+
with HTTP-based tool providers. It supports RESTful APIs, authentication methods,
|
|
5
|
+
URL path parameters, and automatic tool discovery through various formats.
|
|
6
|
+
|
|
7
|
+
Key Features:
|
|
8
|
+
- Multiple authentication methods (API key, Basic, OAuth2)
|
|
9
|
+
- URL path parameter substitution
|
|
10
|
+
- Automatic tool discovery from UTCP manuals, OpenAPI specs, and YAML
|
|
11
|
+
- Security enforcement (HTTPS or localhost only)
|
|
12
|
+
- Request/response handling with proper error management
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Dict, Any, List, Optional, Callable, AsyncGenerator
|
|
16
|
+
import aiohttp
|
|
17
|
+
import json
|
|
18
|
+
import yaml
|
|
19
|
+
import base64
|
|
20
|
+
import re
|
|
21
|
+
import traceback
|
|
22
|
+
|
|
23
|
+
from utcp.interfaces.communication_protocol import CommunicationProtocol
|
|
24
|
+
from utcp.data.call_template import CallTemplate
|
|
25
|
+
from utcp.data.tool import Tool
|
|
26
|
+
from utcp.data.utcp_manual import UtcpManual, UtcpManualSerializer
|
|
27
|
+
from utcp.data.register_manual_response import RegisterManualResult
|
|
28
|
+
from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth
|
|
29
|
+
from utcp.data.auth_implementations.basic_auth import BasicAuth
|
|
30
|
+
from utcp.data.auth_implementations.oauth2_auth import OAuth2Auth
|
|
31
|
+
from utcp_http.http_call_template import HttpCallTemplate
|
|
32
|
+
from aiohttp import ClientSession, BasicAuth as AiohttpBasicAuth
|
|
33
|
+
from utcp_http.openapi_converter import OpenApiConverter
|
|
34
|
+
import logging
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
class HttpCommunicationProtocol(CommunicationProtocol):
|
|
39
|
+
"""HTTP communication protocol implementation for UTCP client.
|
|
40
|
+
|
|
41
|
+
Handles communication with HTTP-based tool providers, supporting various
|
|
42
|
+
authentication methods, URL path parameters, and automatic tool discovery.
|
|
43
|
+
Enforces security by requiring HTTPS or localhost connections.
|
|
44
|
+
|
|
45
|
+
Features:
|
|
46
|
+
- RESTful API communication with configurable HTTP methods
|
|
47
|
+
- Multiple authentication: API key (header/query/cookie), Basic, OAuth2
|
|
48
|
+
- URL path parameter substitution from tool arguments
|
|
49
|
+
- Tool discovery from UTCP manuals, OpenAPI specs, and YAML
|
|
50
|
+
- Request body and header field mapping from tool arguments
|
|
51
|
+
- OAuth2 token caching and automatic refresh
|
|
52
|
+
- Security validation of connection URLs
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
_session: Optional aiohttp ClientSession for connection reuse.
|
|
56
|
+
_oauth_tokens: Cache of OAuth2 tokens by client_id.
|
|
57
|
+
_log: Logger function for debugging and error reporting.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, logger: Optional[Callable[[str], None]] = None):
|
|
61
|
+
"""Initialize the HTTP transport.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
logger: Optional logging function that accepts log messages.
|
|
65
|
+
Defaults to a no-op function if not provided.
|
|
66
|
+
"""
|
|
67
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
68
|
+
self._oauth_tokens: Dict[str, Dict[str, Any]] = {}
|
|
69
|
+
|
|
70
|
+
def _apply_auth(self, provider: HttpCallTemplate, headers: Dict[str, str], query_params: Dict[str, Any]) -> tuple:
|
|
71
|
+
"""Apply authentication to the request based on the provider's auth configuration.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
tuple: (auth_obj, cookies) where auth_obj is for aiohttp basic auth and cookies is a dict
|
|
75
|
+
"""
|
|
76
|
+
auth = None
|
|
77
|
+
cookies = {}
|
|
78
|
+
|
|
79
|
+
if provider.auth:
|
|
80
|
+
if isinstance(provider.auth, ApiKeyAuth):
|
|
81
|
+
if provider.auth.api_key:
|
|
82
|
+
if provider.auth.location == "header":
|
|
83
|
+
headers[provider.auth.var_name] = provider.auth.api_key
|
|
84
|
+
elif provider.auth.location == "query":
|
|
85
|
+
query_params[provider.auth.var_name] = provider.auth.api_key
|
|
86
|
+
elif provider.auth.location == "cookie":
|
|
87
|
+
cookies[provider.auth.var_name] = provider.auth.api_key
|
|
88
|
+
else:
|
|
89
|
+
logger.error("API key not found for ApiKeyAuth.")
|
|
90
|
+
raise ValueError("API key for ApiKeyAuth not found.")
|
|
91
|
+
|
|
92
|
+
elif isinstance(provider.auth, BasicAuth):
|
|
93
|
+
auth = AiohttpBasicAuth(provider.auth.username, provider.auth.password)
|
|
94
|
+
|
|
95
|
+
elif isinstance(provider.auth, OAuth2Auth):
|
|
96
|
+
# OAuth2 tokens are always sent in the Authorization header
|
|
97
|
+
# We'll handle this separately since it requires async token retrieval
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
return auth, cookies
|
|
101
|
+
|
|
102
|
+
async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
|
|
103
|
+
"""Register a manual and its tools.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
caller: The UTCP client that is calling this method.
|
|
107
|
+
manual_call_template: The call template of the manual to register.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
RegisterManualResult object containing the call template and manual.
|
|
111
|
+
"""
|
|
112
|
+
if not isinstance(manual_call_template, HttpCallTemplate):
|
|
113
|
+
raise ValueError("HttpCommunicationProtocol can only be used with HttpCallTemplate")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
url = manual_call_template.url
|
|
117
|
+
|
|
118
|
+
# Security check: Enforce HTTPS or localhost to prevent MITM attacks
|
|
119
|
+
if not (url.startswith("https://") or url.startswith("http://localhost") or url.startswith("http://127.0.0.1")):
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Security error: URL must use HTTPS or start with 'http://localhost' or 'http://127.0.0.1'. Got: {url}. "
|
|
122
|
+
"Non-secure URLs are vulnerable to man-in-the-middle attacks."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
logger.info(f"Discovering tools from '{manual_call_template.name}' (HTTP) at {url}")
|
|
126
|
+
|
|
127
|
+
# Use the call template's configuration (headers, auth, HTTP method, etc.)
|
|
128
|
+
request_headers = manual_call_template.headers.copy() if manual_call_template.headers else {}
|
|
129
|
+
body_content = None
|
|
130
|
+
query_params = {}
|
|
131
|
+
|
|
132
|
+
# Handle authentication
|
|
133
|
+
auth, cookies = self._apply_auth(manual_call_template, request_headers, query_params)
|
|
134
|
+
|
|
135
|
+
# Handle OAuth2 separately since it requires async token retrieval
|
|
136
|
+
if manual_call_template.auth and isinstance(manual_call_template.auth, OAuth2Auth):
|
|
137
|
+
token = await self._handle_oauth2(manual_call_template.auth)
|
|
138
|
+
request_headers["Authorization"] = f"Bearer {token}"
|
|
139
|
+
|
|
140
|
+
# Handle body content if specified
|
|
141
|
+
if manual_call_template.body_field:
|
|
142
|
+
# For discovery, we typically don't have body content, but support it if needed
|
|
143
|
+
body_content = None
|
|
144
|
+
|
|
145
|
+
async with aiohttp.ClientSession() as session:
|
|
146
|
+
try:
|
|
147
|
+
# Set content-type header if body is provided and header not already set
|
|
148
|
+
if body_content is not None and "Content-Type" not in request_headers:
|
|
149
|
+
request_headers["Content-Type"] = manual_call_template.content_type
|
|
150
|
+
|
|
151
|
+
# Prepare body content based on content type
|
|
152
|
+
data = None
|
|
153
|
+
json_data = None
|
|
154
|
+
if body_content is not None:
|
|
155
|
+
if "application/json" in request_headers.get("Content-Type", ""):
|
|
156
|
+
json_data = body_content
|
|
157
|
+
else:
|
|
158
|
+
data = body_content
|
|
159
|
+
|
|
160
|
+
# Make the request with the call template's HTTP method
|
|
161
|
+
method = manual_call_template.http_method.lower()
|
|
162
|
+
request_method = getattr(session, method)
|
|
163
|
+
|
|
164
|
+
async with request_method(
|
|
165
|
+
url,
|
|
166
|
+
params=query_params,
|
|
167
|
+
headers=request_headers,
|
|
168
|
+
auth=auth,
|
|
169
|
+
json=json_data,
|
|
170
|
+
data=data,
|
|
171
|
+
cookies=cookies,
|
|
172
|
+
timeout=aiohttp.ClientTimeout(total=10.0)
|
|
173
|
+
) as response:
|
|
174
|
+
response.raise_for_status() # Raise exception for 4XX/5XX responses
|
|
175
|
+
|
|
176
|
+
# Check content type to determine how to parse the response
|
|
177
|
+
content_type = response.headers.get('Content-Type', '')
|
|
178
|
+
response_text = await response.text()
|
|
179
|
+
|
|
180
|
+
if 'yaml' in content_type or url.endswith(('.yaml', '.yml')):
|
|
181
|
+
response_data = yaml.safe_load(response_text)
|
|
182
|
+
else:
|
|
183
|
+
response_data = json.loads(response_text)
|
|
184
|
+
|
|
185
|
+
# Check if the response is a UTCP manual or an OpenAPI spec
|
|
186
|
+
if "utcp_version" in response_data and "tools" in response_data:
|
|
187
|
+
logger.info(f"Detected UTCP manual from '{manual_call_template.name}'.")
|
|
188
|
+
utcp_manual = UtcpManualSerializer().validate_dict(response_data)
|
|
189
|
+
else:
|
|
190
|
+
logger.info(f"Assuming OpenAPI spec from '{manual_call_template.name}'. Converting to UTCP manual.")
|
|
191
|
+
converter = OpenApiConverter(response_data, spec_url=manual_call_template.url, call_template_name=manual_call_template.name)
|
|
192
|
+
utcp_manual = converter.convert()
|
|
193
|
+
|
|
194
|
+
return RegisterManualResult(
|
|
195
|
+
success=True,
|
|
196
|
+
manual_call_template=manual_call_template,
|
|
197
|
+
manual=utcp_manual,
|
|
198
|
+
errors=[]
|
|
199
|
+
)
|
|
200
|
+
except aiohttp.ClientResponseError as e:
|
|
201
|
+
error_msg = f"Error connecting to HTTP provider '{manual_call_template.name}': {e}"
|
|
202
|
+
logger.error(error_msg)
|
|
203
|
+
return RegisterManualResult(
|
|
204
|
+
success=False,
|
|
205
|
+
manual_call_template=manual_call_template,
|
|
206
|
+
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
|
|
207
|
+
errors=[error_msg]
|
|
208
|
+
)
|
|
209
|
+
except (json.JSONDecodeError, yaml.YAMLError) as e:
|
|
210
|
+
error_msg = f"Error parsing spec from HTTP provider '{manual_call_template.name}': {e}"
|
|
211
|
+
logger.error(error_msg)
|
|
212
|
+
return RegisterManualResult(
|
|
213
|
+
success=False,
|
|
214
|
+
manual_call_template=manual_call_template,
|
|
215
|
+
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
|
|
216
|
+
errors=[error_msg]
|
|
217
|
+
)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
error_msg = f"Unexpected error discovering tools from HTTP provider '{manual_call_template.name}': {traceback.format_exc()}"
|
|
220
|
+
logger.error(error_msg)
|
|
221
|
+
return RegisterManualResult(
|
|
222
|
+
success=False,
|
|
223
|
+
manual_call_template=manual_call_template,
|
|
224
|
+
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
|
|
225
|
+
errors=[error_msg]
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None:
|
|
229
|
+
"""Deregister a manual and its tools.
|
|
230
|
+
|
|
231
|
+
Deregistering a manual is a no-op for the stateless HTTP communication protocol.
|
|
232
|
+
"""
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
|
|
236
|
+
"""Execute a tool call through this transport.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
caller: The UTCP client that is calling this method.
|
|
240
|
+
tool_name: Name of the tool to call (may include provider prefix).
|
|
241
|
+
tool_args: Dictionary of arguments to pass to the tool.
|
|
242
|
+
tool_call_template: Call template of the tool to call.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The tool's response, with type depending on the tool's output schema.
|
|
246
|
+
"""
|
|
247
|
+
if not isinstance(tool_call_template, HttpCallTemplate):
|
|
248
|
+
raise ValueError("HttpCommunicationProtocol can only be used with HttpCallTemplate")
|
|
249
|
+
|
|
250
|
+
request_headers = tool_call_template.headers.copy() if tool_call_template.headers else {}
|
|
251
|
+
body_content = None
|
|
252
|
+
remaining_args = tool_args.copy()
|
|
253
|
+
|
|
254
|
+
# Handle header fields
|
|
255
|
+
if tool_call_template.header_fields:
|
|
256
|
+
for field_name in tool_call_template.header_fields:
|
|
257
|
+
if field_name in remaining_args:
|
|
258
|
+
request_headers[field_name] = str(remaining_args.pop(field_name))
|
|
259
|
+
|
|
260
|
+
# Handle body field
|
|
261
|
+
if tool_call_template.body_field and tool_call_template.body_field in remaining_args:
|
|
262
|
+
body_content = remaining_args.pop(tool_call_template.body_field)
|
|
263
|
+
|
|
264
|
+
# Build the URL with path parameters substituted
|
|
265
|
+
url = self._build_url_with_path_params(tool_call_template.url, remaining_args)
|
|
266
|
+
|
|
267
|
+
# The rest of the arguments are query parameters
|
|
268
|
+
query_params = remaining_args
|
|
269
|
+
|
|
270
|
+
# Handle authentication
|
|
271
|
+
auth, cookies = self._apply_auth(tool_call_template, request_headers, query_params)
|
|
272
|
+
|
|
273
|
+
# Handle OAuth2 separately since it requires async token retrieval
|
|
274
|
+
if tool_call_template.auth and isinstance(tool_call_template.auth, OAuth2Auth):
|
|
275
|
+
token = await self._handle_oauth2(tool_call_template.auth)
|
|
276
|
+
request_headers["Authorization"] = f"Bearer {token}"
|
|
277
|
+
|
|
278
|
+
async with aiohttp.ClientSession() as session:
|
|
279
|
+
try:
|
|
280
|
+
# Set content-type header if body is provided and header not already set
|
|
281
|
+
if body_content is not None and "Content-Type" not in request_headers:
|
|
282
|
+
request_headers["Content-Type"] = tool_call_template.content_type
|
|
283
|
+
|
|
284
|
+
# Prepare body content based on content type
|
|
285
|
+
data = None
|
|
286
|
+
json_data = None
|
|
287
|
+
if body_content is not None:
|
|
288
|
+
if "application/json" in request_headers.get("Content-Type", ""):
|
|
289
|
+
json_data = body_content
|
|
290
|
+
else:
|
|
291
|
+
data = body_content
|
|
292
|
+
|
|
293
|
+
# Make the request with the appropriate HTTP method
|
|
294
|
+
method = tool_call_template.http_method.lower()
|
|
295
|
+
request_method = getattr(session, method)
|
|
296
|
+
|
|
297
|
+
async with request_method(
|
|
298
|
+
url,
|
|
299
|
+
params=query_params,
|
|
300
|
+
headers=request_headers,
|
|
301
|
+
auth=auth,
|
|
302
|
+
json=json_data,
|
|
303
|
+
data=data,
|
|
304
|
+
cookies=cookies,
|
|
305
|
+
timeout=aiohttp.ClientTimeout(total=30.0)
|
|
306
|
+
) as response:
|
|
307
|
+
response.raise_for_status()
|
|
308
|
+
return await response.json()
|
|
309
|
+
|
|
310
|
+
except aiohttp.ClientResponseError as e:
|
|
311
|
+
logger.error(f"Error calling tool '{tool_name}' on call template '{tool_call_template.name}': {e}")
|
|
312
|
+
raise
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.error(f"Unexpected error calling tool '{tool_name}': {e}")
|
|
315
|
+
raise
|
|
316
|
+
|
|
317
|
+
async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]:
|
|
318
|
+
"""Execute a tool call through this transport streamingly.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
caller: The UTCP client that is calling this method.
|
|
322
|
+
tool_name: Name of the tool to call (may include provider prefix).
|
|
323
|
+
tool_args: Dictionary of arguments to pass to the tool.
|
|
324
|
+
tool_call_template: Call template of the tool to call.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
An async generator that yields the tool's response.
|
|
328
|
+
"""
|
|
329
|
+
# For HTTP, streaming is not typically supported, so we'll just yield the complete response
|
|
330
|
+
result = await self.call_tool(caller, tool_name, tool_args, tool_call_template)
|
|
331
|
+
yield result
|
|
332
|
+
|
|
333
|
+
async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str:
|
|
334
|
+
"""Handles OAuth2 client credentials flow, trying both body and auth header methods."""
|
|
335
|
+
client_id = auth_details.client_id
|
|
336
|
+
|
|
337
|
+
if client_id in self._oauth_tokens:
|
|
338
|
+
return self._oauth_tokens[client_id]["access_token"]
|
|
339
|
+
|
|
340
|
+
async with aiohttp.ClientSession() as session:
|
|
341
|
+
# Method 1: Send credentials in the request body
|
|
342
|
+
try:
|
|
343
|
+
logger.info("Attempting OAuth2 token fetch with credentials in body.")
|
|
344
|
+
body_data = {
|
|
345
|
+
'grant_type': 'client_credentials',
|
|
346
|
+
'client_id': auth_details.client_id,
|
|
347
|
+
'client_secret': auth_details.client_secret,
|
|
348
|
+
'scope': auth_details.scope
|
|
349
|
+
}
|
|
350
|
+
async with session.post(auth_details.token_url, data=body_data) as response:
|
|
351
|
+
response.raise_for_status()
|
|
352
|
+
token_response = await response.json()
|
|
353
|
+
self._oauth_tokens[client_id] = token_response
|
|
354
|
+
return token_response["access_token"]
|
|
355
|
+
except aiohttp.ClientError as e:
|
|
356
|
+
logger.error(f"OAuth2 with credentials in body failed: {e}. Trying Basic Auth header.")
|
|
357
|
+
|
|
358
|
+
# Method 2: Send credentials as Basic Auth header
|
|
359
|
+
try:
|
|
360
|
+
logger.info("Attempting OAuth2 token fetch with Basic Auth header.")
|
|
361
|
+
header_auth = AiohttpBasicAuth(auth_details.client_id, auth_details.client_secret)
|
|
362
|
+
header_data = {
|
|
363
|
+
'grant_type': 'client_credentials',
|
|
364
|
+
'scope': auth_details.scope
|
|
365
|
+
}
|
|
366
|
+
async with session.post(auth_details.token_url, data=header_data, auth=header_auth) as response:
|
|
367
|
+
response.raise_for_status()
|
|
368
|
+
token_response = await response.json()
|
|
369
|
+
self._oauth_tokens[client_id] = token_response
|
|
370
|
+
return token_response["access_token"]
|
|
371
|
+
except aiohttp.ClientError as e:
|
|
372
|
+
logger.error(f"OAuth2 with Basic Auth header also failed: {e}")
|
|
373
|
+
|
|
374
|
+
def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, Any]) -> str:
|
|
375
|
+
"""Build URL by substituting path parameters from arguments.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
url_template: URL template with path parameters in {param_name} format
|
|
379
|
+
tool_args: Dictionary of arguments that will be modified to remove used path parameters
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
URL with path parameters substituted
|
|
383
|
+
|
|
384
|
+
Example:
|
|
385
|
+
url_template = "https://api.example.com/users/{user_id}/posts/{post_id}"
|
|
386
|
+
tool_args = {"user_id": "123", "post_id": "456", "limit": "10"}
|
|
387
|
+
Returns: "https://api.example.com/users/123/posts/456"
|
|
388
|
+
And modifies tool_args to: {"limit": "10"}
|
|
389
|
+
"""
|
|
390
|
+
# Find all path parameters in the URL template
|
|
391
|
+
path_params = re.findall(r'\{([^}]+)\}', url_template)
|
|
392
|
+
|
|
393
|
+
url = url_template
|
|
394
|
+
for param_name in path_params:
|
|
395
|
+
if param_name in tool_args:
|
|
396
|
+
# Replace the parameter in the URL
|
|
397
|
+
param_value = str(tool_args[param_name])
|
|
398
|
+
url = url.replace(f'{{{param_name}}}', param_value)
|
|
399
|
+
# Remove the parameter from arguments so it's not used as a query parameter
|
|
400
|
+
tool_args.pop(param_name)
|
|
401
|
+
else:
|
|
402
|
+
raise ValueError(f"Missing required path parameter: {param_name}")
|
|
403
|
+
|
|
404
|
+
# Check if there are any unreplaced path parameters
|
|
405
|
+
remaining_params = re.findall(r'\{([^}]+)\}', url)
|
|
406
|
+
if remaining_params:
|
|
407
|
+
raise ValueError(f"Missing required path parameters: {remaining_params}")
|
|
408
|
+
|
|
409
|
+
return url
|