Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions embodichain/gen_sim/prompt2scene/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
cli/preview*
cli/export*
agent_tools/servers/geometry_generation_server/*

# Python cache
__pycache__/
*.py[cod]
15 changes: 15 additions & 0 deletions embodichain/gen_sim/prompt2scene/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------------
Comment on lines +1 to +15
1 change: 1 addition & 0 deletions embodichain/gen_sim/prompt2scene/agent_tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Internal client + External server for agent tool calling."""
31 changes: 31 additions & 0 deletions embodichain/gen_sim/prompt2scene/agent_tools/clients/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------------

from __future__ import annotations

from embodichain.gen_sim.prompt2scene.agent_tools.clients.base import BaseHttpClient
from embodichain.gen_sim.prompt2scene.agent_tools.clients.common import ClientError
from embodichain.gen_sim.prompt2scene.agent_tools.clients.config import (
DEFAULT_CLIENT_CONFIG_PATH,
load_client_config,
)

__all__ = [
"BaseHttpClient",
"ClientError",
"DEFAULT_CLIENT_CONFIG_PATH",
"load_client_config",
]
131 changes: 131 additions & 0 deletions embodichain/gen_sim/prompt2scene/agent_tools/clients/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------------

from __future__ import annotations

import time
from pathlib import Path
from typing import Callable

import requests

from embodichain.gen_sim.prompt2scene.agent_tools.clients.common import (
ClientError,
build_client_error,
)
from embodichain.gen_sim.prompt2scene.agent_tools.clients.config import (
load_client_config,
)
from embodichain.gen_sim.prompt2scene.utils.log import (
log_api_request_start,
log_info,
log_warning,
)

__all__ = ["BaseHttpClient"]


class BaseHttpClient:
"""Shared HTTP client behavior for agent-tool service clients."""

def __init__(
self,
*,
config_key: str,
server_name: str,
base_url: str | None = None,
timeout_s: int | None = None,
config_path: Path | None = None,
session: requests.Session | None = None,
trust_env: bool = True,
) -> None:
"""Initialize common service client fields from config."""
self.config = load_client_config(config_key, config_path)
self.server_name = server_name
self.base_url = (base_url or str(self.config["base_url"])).rstrip("/")
self.timeout_s = int(timeout_s or self.config.get("timeout_s", 120))
self.health_path = str(self.config.get("health_path", "/health"))
self.session = session or requests.Session()
self.session.trust_env = trust_env
log_info(f"{self.server_name} client initialized for {self.base_url}")

def health_check(self) -> bool:
"""Check whether the configured service is healthy."""
try:
response = self.session.get(
f"{self.base_url}{self.health_path}",
timeout=5,
)
response.raise_for_status()
return True
except Exception as exc:
log_warning(f"{self.server_name} health check failed: {exc}")
return False

def post_with_retries(
self,
request_fn: Callable[[], requests.Response],
*,
max_retries: int,
error_cls: type[ClientError] = ClientError,
request_label: str | None = None,
) -> requests.Response | ClientError:
"""Run a POST request function with retry and HTTP error handling."""
for attempt in range(max_retries):
try:
if request_label is not None:
log_api_request_start(
step=self.server_name,
request=request_label,
attempt=attempt + 1,
)
response = request_fn()
response.raise_for_status()
return response

except requests.exceptions.ConnectionError as exc:
if attempt < max_retries - 1:
log_warning(
f"{self.server_name} connection failed; retrying "
f"({attempt + 1}/{max_retries})."
)
time.sleep(min(2**attempt, 60))
continue
raise ConnectionError(
f"Failed to connect to {self.server_name} at {self.base_url}"
) from exc

except requests.exceptions.HTTPError as exc:
response = exc.response
if response is None:
raise RuntimeError(f"{self.server_name} HTTP request failed.") from exc
if response.status_code >= 500 and attempt < max_retries - 1:
log_warning(
f"{self.server_name} server error; retrying "
f"({attempt + 1}/{max_retries})."
)
time.sleep(min(2**attempt, 60))
continue
return build_client_error(
response,
server_name=self.server_name,
error_cls=error_cls,
)

except requests.exceptions.Timeout as exc:
raise TimeoutError(f"{self.server_name} request timed out.") from exc

raise RuntimeError(f"{self.server_name} request failed unexpectedly.")
139 changes: 139 additions & 0 deletions embodichain/gen_sim/prompt2scene/agent_tools/clients/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------------

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

import requests

__all__ = [
"ClientError",
"build_client_error",
"first_string",
"format_http_error",
"parse_error_response",
"parse_json_object_response",
"validate_required_strings",
"validate_png_response",
]


@dataclass(frozen=True)
class ClientError:
"""Common HTTP client error response."""

error_message: str
status_code: int | None = None
content_type: str | None = None
headers: dict[str, str] = field(default_factory=dict)
raw_response: dict[str, Any] | None = None


def validate_png_response(
response: requests.Response,
png_bytes: bytes,
) -> None:
content_type = response.headers.get("Content-Type", "")
if "image/png" not in content_type.lower():
raise RuntimeError(
"Image generation server returned non-PNG content: "
f"{content_type or 'unknown'}"
)
if not png_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
raise RuntimeError("Image generation server returned invalid PNG bytes.")


def validate_required_strings(fields: dict[str, object]) -> None:
"""Validate required client request string fields."""
for field_name, value in fields.items():
if not str(value).strip():
raise ValueError(f"{field_name} must be non-empty.")


def format_http_error(response: requests.Response, *, server_name: str) -> str:
"""Format an HTTP error response from an agent-tool server."""
try:
response_data = response.json()
except ValueError:
return f"{server_name} HTTP error: {response.status_code}"

error_message = first_string(
response_data,
"error",
"error_message",
"message",
"detail",
)
if error_message:
return f"{server_name} error: {error_message}"
return f"{server_name} HTTP error: {response.status_code}"


def parse_error_response(response: requests.Response) -> dict[str, Any] | None:
"""Parse an error response body as a JSON object if possible."""
try:
response_data = response.json()
except ValueError:
return None
return response_data if isinstance(response_data, dict) else None


def build_client_error(
response: requests.Response,
*,
server_name: str,
error_cls: type[ClientError] = ClientError,
) -> ClientError:
"""Build a common client error dataclass from an HTTP response."""
return error_cls(
error_message=format_http_error(
response,
server_name=server_name,
),
status_code=response.status_code,
content_type=response.headers.get("Content-Type"),
headers=dict(response.headers),
raw_response=parse_error_response(response),
)


def parse_json_object_response(
response: requests.Response,
*,
server_name: str,
) -> dict[str, Any]:
"""Parse an HTTP response body as a JSON object."""
try:
response_data = response.json()
except ValueError as exc:
raise RuntimeError(
f"{server_name} returned invalid JSON content: "
f"{response.headers.get('Content-Type') or 'unknown'}"
) from exc
if not isinstance(response_data, dict):
raise RuntimeError(f"{server_name} response must be a JSON object.")
return response_data


def first_string(data: dict[str, Any], *keys: str) -> str | None:
"""Return the first string value for the given keys."""
for key in keys:
value = data.get(key)
if isinstance(value, str):
return value
return None
50 changes: 50 additions & 0 deletions embodichain/gen_sim/prompt2scene/agent_tools/clients/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------------

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

__all__ = ["DEFAULT_CLIENT_CONFIG_PATH", "load_client_config"]

DEFAULT_CLIENT_CONFIG_PATH = (
Path(__file__).resolve().parents[2] / "configs" / "client_config.json"
)


def load_client_config(
config_key: str,
config_path: Path | None = None,
) -> dict[str, Any]:
"""Load one agent-tool client config section."""
resolved_config_path = (config_path or DEFAULT_CLIENT_CONFIG_PATH).resolve()
if not resolved_config_path.is_file():
raise FileNotFoundError(f"Client config not found: {resolved_config_path}")

with resolved_config_path.open("r", encoding="utf-8") as f:
raw_config = json.load(f)

config = raw_config.get(config_key)
if not isinstance(config, dict):
raise ValueError(
f"Client config section {config_key!r} not found in "
f"{resolved_config_path}"
)
if not config.get("base_url"):
raise ValueError(f"Client config section {config_key!r} requires base_url.")
return config
Loading
Loading