ReMe/reme/components/client/http_client.py
jinliyl 10da205797
Some checks are pending
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
feat(benchmark): add lme benchmark steps (#326)
* refactor(search): replace time module with datetime for timestamp generation

- Removed unused time import
- Added static method _now_ts using datetime.timestamp
- Updated clock parameter to use _now_ts method instead of time.time
- Maintained same timestamp precision and functionality

* test(http): add tests for HTTP client display formatting

- Add test for default metadata hiding behavior in CLI output
- Add test for metadata display when show_metadata is enabled
- Verify _format_for_display method correctly formats response text
- Test both success case and metadata inclusion scenarios

* chore(build): remove longmemeval from gitignore

- Removed longmemeval directory from gitignore list
- Kept evaluation and datasets directories in ignore list
- Updated gitignore configuration for proper version control
2026-07-07 18:53:39 +09:00

142 lines
5.8 KiB
Python

"""HTTP client for ReMe services."""
import json
import os
from collections.abc import AsyncGenerator
import httpx
from .base_client import BaseClient
from ..component_registry import R
from ...constants import REME_SERVICE_INFO, REME_DEFAULT_HOST, REME_DEFAULT_PORT
from ...enumeration import ChunkEnum
from ...schema import StreamChunk
@R.register("http")
class HttpClient(BaseClient):
"""HTTP client that auto-adapts to JSON or SSE endpoints via Content-Type."""
def __init__(
self,
host: str | None = None,
port: int | None = None,
timeout: float = 3600.0,
show_metadata: bool = False,
**kwargs,
):
super().__init__(**kwargs)
# Resolve host/port: explicit args > env var > defaults
if not (host and port):
if service_info := os.environ.get(REME_SERVICE_INFO):
try:
data = json.loads(service_info)
host = data["host"]
port = data["port"]
except Exception:
self.logger.warning(f"Invalid service info: {service_info}")
host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
else:
host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
self.base_url = f"http://{host}:{port}"
self.timeout = timeout
self.show_metadata = show_metadata
async def _start(self) -> None:
"""Initialize the HTTP client."""
if self.client is None:
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout)
async def _iter_stream_chunks(self, action: str, payload: dict) -> AsyncGenerator[StreamChunk, None]:
"""Send request and yield raw StreamChunks; auto-detects JSON vs SSE via Content-Type.
For JSON responses: yields a single CONTENT chunk with the raw response body.
For SSE responses: yields each streaming chunk as it arrives.
"""
if self.client is None:
raise RuntimeError("Client not initialized. Call _start() first.")
async with self.client.stream("POST", f"/{action}", json=payload) as resp:
resp.raise_for_status()
ctype = resp.headers.get("content-type", "")
if ctype.startswith("text/event-stream"):
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
data_str = line[len("data:") :]
if data_str.strip() == "[DONE]":
return
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
chunk = StreamChunk(**data)
if chunk.chunk_type == ChunkEnum.ERROR:
# Surface server-side errors as exceptions so callers don't
# mistake error chunks for valid content.
raise RuntimeError(str(chunk.chunk))
if chunk.done:
return
yield chunk
else:
body = await resp.aread()
yield StreamChunk(chunk_type=ChunkEnum.CONTENT, chunk=body.decode())
async def stream_chunks(self, action: str, **kwargs) -> AsyncGenerator[StreamChunk, None]:
"""HTTP-specific richer access: yield raw StreamChunk objects (no display formatting)."""
async for chunk in self._iter_stream_chunks(action, kwargs):
yield chunk
async def list_actions(self) -> list[dict]:
"""Return raw OpenAPI operations; each dict gets an `action` key (path without leading '/')."""
if self.client is None:
raise RuntimeError("Client not initialized. Call _start() first.")
resp = await self.client.get("/openapi.json")
resp.raise_for_status()
spec = resp.json()
actions: list[dict] = []
for path, methods in spec.get("paths", {}).items():
for method, op in methods.items():
actions.append({"action": path.lstrip("/"), "method": method.upper(), **op})
return actions
def _format_for_display(self, text: str) -> str:
"""Render a JSON response as human-friendly CLI text; pass through unrecognized payloads."""
try:
data = json.loads(text)
except (ValueError, json.JSONDecodeError):
return text
if not (isinstance(data, dict) and isinstance(data.get("answer"), str)):
return json.dumps(data, indent=2, ensure_ascii=False) if isinstance(data, (dict, list)) else text
d = dict(data)
answer = d.pop("answer")
success = d.pop("success", None)
metadata = d.pop("metadata", None)
parts = [answer]
status_pieces = []
if success is not None:
status_pieces.append("" if success else "")
if self.show_metadata and metadata:
status_pieces.append(json.dumps(metadata, ensure_ascii=False))
if status_pieces:
parts.append(" ".join(status_pieces))
if d:
parts.append(json.dumps(d, indent=2, ensure_ascii=False))
return "\n".join(parts)
# pylint: disable=invalid-overridden-method
async def _execute(self, action: str, payload: dict) -> AsyncGenerator[str, None]:
"""Yield text chunks for CLI display; JSON responses are pretty-formatted."""
async for chunk in self._iter_stream_chunks(action, payload):
chunk_payload = chunk.chunk
text = chunk_payload if isinstance(chunk_payload, str) else json.dumps(chunk_payload, ensure_ascii=False)
yield self._format_for_display(text)
async def _close(self) -> None:
"""Close the HTTP client."""
if self.client is not None:
await self.client.aclose()
self.client = None