mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
* 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
142 lines
5.8 KiB
Python
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
|