"""HTTP client for HIRO Translation API (public connect gateway or internal stage).""" from __future__ import annotations import json import os from collections.abc import Iterator from typing import Any import requests # Public gateway (matches curl examples against connect.zhihuiya.com). DEFAULT_BASE = "https://connect.zhihuiya.com/hiro_translation" API_KEY_ENV = "HIRO_API_KEY" def lang_to_codes(lang: str) -> tuple[str, str]: src, _, tgt = lang.partition("2") if not src or not tgt: raise ValueError(f"invalid lang {lang!r}; expected format like zh2en") return src, tgt def translate_payload( content: str, lang: str, *, mode: str, ) -> dict[str, Any]: source, target = lang_to_codes(lang) return { "content": content, "sourceLanguageCode": source, "targetLanguageCode": target, "mode": mode, } def _api_url(base_url: str, path: str) -> str: return f"{base_url.rstrip('/')}{path}" def _request_headers(api_key: str | None = None) -> dict[str, str]: headers = {"Content-Type": "application/json"} key = (api_key if api_key is not None else os.environ.get(API_KEY_ENV, "")).strip() if key: headers["Authorization"] = f"Bearer {key}" return headers def _translated_text(data: dict[str, Any]) -> str: value = data.get("textTranslated", data.get("text_translated", "")) return value if isinstance(value, str) else "" def _original_text(data: dict[str, Any], fallback: str = "") -> str: value = data.get("textOriginal", data.get("text_original", fallback)) return value if isinstance(value, str) else fallback def health_ok( base_url: str = DEFAULT_BASE, *, api_key: str | None = None, ) -> tuple[bool, str]: try: r = requests.get( _api_url(base_url, "/health"), headers=_request_headers(api_key), timeout=12, ) r.raise_for_status() data = r.json() if data.get("status") != "OK": return False, f"Unexpected response: {r.text[:200]}" upstream = data.get("upstream", "UNKNOWN") if upstream == "OK": return True, "Healthy ยท upstream OK" return False, f"Gateway up, upstream unavailable ({upstream})" except requests.RequestException as exc: return False, str(exc) def translate_fast( text: str, lang: str, *, base_url: str = DEFAULT_BASE, api_key: str | None = None, timeout: int = 1800, ) -> dict[str, Any]: r = requests.post( _api_url(base_url, "/translate"), json=translate_payload(text, lang, mode="fast"), headers=_request_headers(api_key), timeout=timeout, ) if r.status_code >= 400: try: err = r.json().get("error", r.text[:500]) except json.JSONDecodeError: err = r.text[:500] raise RuntimeError(err) data = r.json() if data.get("state") not in (None, "success") and "error" in data: raise RuntimeError(str(data.get("error", data))) # Normalize to keys the UI already understands (snake_case aliases). return { "state": data.get("state", "success"), "text_original": _original_text(data, text), "text_translated": _translated_text(data), "translated_character_count": data.get("translatedCharacterCount"), "billing_amount": r.headers.get("X-Openapi-Amount"), "raw": data, } def _iter_sse_json(resp: requests.Response) -> Iterator[dict[str, Any]]: for raw in resp.iter_lines(decode_unicode=True): if not raw: continue line = raw.strip() if not line.startswith("data:"): continue payload = line[5:].lstrip() if not payload: continue chunk = json.loads(payload) if isinstance(chunk, dict) and chunk.get("error"): raise RuntimeError(str(chunk["error"])) if isinstance(chunk, dict): yield { "state": chunk.get("state", "success"), "text_original": _original_text(chunk), "text_translated": _translated_text(chunk), "translated_character_count": chunk.get("translatedCharacterCount"), "progress": chunk.get("progress"), "raw": chunk, } def stream_translate( text: str, lang: str, *, base_url: str = DEFAULT_BASE, api_key: str | None = None, timeout: int = 1800, ) -> Iterator[dict[str, Any]]: with requests.post( _api_url(base_url, "/translate"), json=translate_payload(text, lang, mode="stream"), headers=_request_headers(api_key), stream=True, timeout=timeout, ) as resp: if resp.status_code >= 400: try: err = resp.json().get("error", resp.text[:500]) except (json.JSONDecodeError, ValueError): err = resp.text[:500] raise RuntimeError(err) yield from _iter_sse_json(resp)