tool_client.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. import httpx
  2. from core_domain import ToolBindingDetailContract
  3. from core_shared import JSONValue
  4. class ToolServiceClientError(Exception):
  5. pass
  6. class ToolServiceClient:
  7. def __init__(self, base_url: str, timeout_seconds: float = 10.0) -> None:
  8. self.base_url = base_url.rstrip("/")
  9. self.timeout_seconds = timeout_seconds
  10. def get_tool_binding_detail(
  11. self,
  12. *,
  13. binding_id: str) -> ToolBindingDetailContract:
  14. try:
  15. with httpx.Client(timeout=self.timeout_seconds) as client:
  16. response = client.get(f"{self.base_url}/tools/bindings/{binding_id}")
  17. response.raise_for_status()
  18. return ToolBindingDetailContract.model_validate(response.json())
  19. except httpx.HTTPError as exc:
  20. raise ToolServiceClientError(f"tool-service lookup failed: {exc}") from exc
  21. def invoke_http_tool(
  22. self,
  23. *,
  24. detail: ToolBindingDetailContract,
  25. input_json: dict[str, JSONValue],
  26. config_json: dict[str, JSONValue]) -> tuple[str | None, dict[str, JSONValue]]:
  27. invoke_config_json = detail.connection.invoke_config_json or {}
  28. binding_config_json = detail.binding.config_json or {}
  29. url = _read_string(config_json, "url") or _read_string(invoke_config_json, "url")
  30. base_url = (
  31. _read_string(config_json, "base_url")
  32. or _read_string(binding_config_json, "base_url")
  33. or _read_string(invoke_config_json, "base_url")
  34. )
  35. path = _read_string(config_json, "path") or _read_string(invoke_config_json, "path")
  36. resolved_url = _resolve_url(url=url, base_url=base_url, path=path)
  37. if resolved_url is None:
  38. raise ToolServiceClientError("http tool requires url or base_url/path")
  39. method = (_read_string(invoke_config_json, "method") or "GET").upper()
  40. headers = _merge_string_maps(
  41. _read_dict(invoke_config_json, "headers"),
  42. _read_dict(binding_config_json, "headers"),
  43. _read_dict(config_json, "headers"))
  44. params = _merge_string_maps(
  45. _read_dict(invoke_config_json, "query"),
  46. _read_dict(config_json, "query"))
  47. body_json = _merge_json_dicts(
  48. _read_dict(invoke_config_json, "body"),
  49. _read_dict(config_json, "body"))
  50. if not body_json and method not in {"GET", "HEAD"}:
  51. body_json = input_json
  52. try:
  53. with httpx.Client(timeout=self.timeout_seconds) as client:
  54. response = client.request(
  55. method=method,
  56. url=resolved_url,
  57. headers=headers,
  58. params=params,
  59. json=None if method in {"GET", "HEAD"} else body_json)
  60. response.raise_for_status()
  61. except httpx.HTTPError as exc:
  62. raise ToolServiceClientError(f"http tool invocation failed: {exc}") from exc
  63. response_json = _try_parse_json_response(response)
  64. response_text = None if response_json is not None else response.text
  65. return response_text, {
  66. "tool_binding_id": detail.binding.id,
  67. "tool_code": detail.tool_definition.code,
  68. "tool_connection_id": detail.connection.id,
  69. "tool_name": detail.tool_definition.name,
  70. "request_url": resolved_url,
  71. "request_method": method,
  72. "response_status_code": response.status_code,
  73. "response_json": response_json,
  74. }
  75. def _read_string(payload: dict[str, JSONValue], key: str) -> str | None:
  76. value = payload.get(key)
  77. if isinstance(value, str) and value:
  78. return value
  79. return None
  80. def _read_dict(payload: dict[str, JSONValue], key: str) -> dict[str, JSONValue]:
  81. value = payload.get(key)
  82. if isinstance(value, dict):
  83. return {str(item_key): item_value for item_key, item_value in value.items()}
  84. return {}
  85. def _merge_json_dicts(*items: dict[str, JSONValue]) -> dict[str, JSONValue]:
  86. merged: dict[str, JSONValue] = {}
  87. for item in items:
  88. merged.update(item)
  89. return merged
  90. def _merge_string_maps(*items: dict[str, JSONValue]) -> dict[str, str]:
  91. merged: dict[str, str] = {}
  92. for item in items:
  93. for key, value in item.items():
  94. if isinstance(value, (str, int, float, bool)):
  95. merged[key] = str(value)
  96. return merged
  97. def _resolve_url(*, url: str | None, base_url: str | None, path: str | None) -> str | None:
  98. if url is not None:
  99. return url
  100. if base_url is None or path is None:
  101. return None
  102. return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
  103. def _try_parse_json_response(response: httpx.Response) -> JSONValue | None:
  104. content_type = response.headers.get("content-type", "")
  105. if "json" not in content_type.lower():
  106. return None
  107. try:
  108. value = response.json()
  109. except ValueError:
  110. return None
  111. if isinstance(value, (dict, list, str, int, float, bool)) or value is None:
  112. return value
  113. return None