tool_client.py 5.2 KB

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