"""HTTP clients for HIRO Translation and Smart Doc (public connect gateway).""" from __future__ import annotations import json import os import re import time from collections.abc import Iterator from pathlib import Path from typing import Any import requests # Public gateways (matches connect.zhihuiya.com curl examples). DEFAULT_BASE = "https://connect.zhihuiya.com/hiro_translation" SMARTDOC_URL = "https://connect.zhihuiya.com/rd-llm/v1/documents/doc_parsing" API_KEY_ENV = "HIRO_API_KEY" API_KEY_ENV_FALLBACK = "RD_LLM_API_KEY" MAX_SMARTDOC_BYTES = 10 * 1024 * 1024 # Async fast: submit + poll (aligned with hiro-translation-api). DEFAULT_POLL_INTERVAL_S = 1.5 DEFAULT_POLL_TIMEOUT_S = 1800 def resolve_api_key(api_key: str | None = None) -> str: """Prefer explicit key, then HIRO_API_KEY, then RD_LLM_API_KEY.""" if api_key is not None and str(api_key).strip(): return str(api_key).strip() return ( os.environ.get(API_KEY_ENV, "").strip() or os.environ.get(API_KEY_ENV_FALLBACK, "").strip() ) 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) -> dict[str, Any]: """Request body for POST /translate and POST /translate/async (no mode field).""" source, target = lang_to_codes(lang) return { "content": content, "sourceLanguageCode": source, "targetLanguageCode": target, } def _api_url(base_url: str, path: str) -> str: return f"{base_url.rstrip('/')}{path}" def _request_headers( api_key: str | None = None, *, json_body: bool = True, ) -> dict[str, str]: headers: dict[str, str] = {} if json_body: headers["Content-Type"] = "application/json" key = resolve_api_key(api_key) 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 _http_error_message(resp: requests.Response) -> str: try: body = resp.json() if isinstance(body, dict): return str(body.get("error") or body.get("message") or body) except (json.JSONDecodeError, ValueError): pass return resp.text[:500] 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 _gateway_error_message(data: Any, fallback: str = "") -> str | None: """Extract connect-gateway style errors (error_msg / error_code).""" if not isinstance(data, dict): return None if data.get("error_msg") or data.get("error_code") is not None: msg = data.get("error_msg") or data.get("error") or "gateway error" code = data.get("error_code") if code is not None: return f"[{code}] {msg}" return str(msg) if data.get("error"): return str(data["error"]) if data.get("status") is False: return fallback or str(data) return None def submit_async_translate( text: str, lang: str, *, base_url: str = DEFAULT_BASE, api_key: str | None = None, timeout: int = 60, ) -> dict[str, Any]: """POST /translate/async → {taskId, state}.""" r = requests.post( _api_url(base_url, "/translate/async"), json=translate_payload(text, lang), headers=_request_headers(api_key), timeout=timeout, ) if r.status_code >= 400: raise RuntimeError(_http_error_message(r)) try: data = r.json() except (json.JSONDecodeError, ValueError) as exc: raise RuntimeError(f"invalid JSON from async submit: {r.text[:500]}") from exc gateway_err = _gateway_error_message(data) if gateway_err: raise RuntimeError(gateway_err) task_id = data.get("taskId") if not task_id: raise RuntimeError(f"missing taskId in submit response: {data}") return { "task_id": str(task_id), "state": data.get("state", "pending"), "billing_amount": r.headers.get("X-Openapi-Amount"), "raw": data, } def get_async_translate_result( task_id: str, *, base_url: str = DEFAULT_BASE, api_key: str | None = None, timeout: int = 60, ) -> dict[str, Any]: """GET /translate/async/{taskId}.""" r = requests.get( _api_url(base_url, f"/translate/async/{task_id}"), headers=_request_headers(api_key, json_body=False), timeout=timeout, ) if r.status_code == 404: raise RuntimeError(f"task not found: {_http_error_message(r)}") if r.status_code >= 400: raise RuntimeError(_http_error_message(r)) data = r.json() return { "task_id": data.get("taskId", task_id), "state": data.get("state"), "text_translated": _translated_text(data), "translated_character_count": data.get("translatedCharacterCount"), "error": data.get("error"), "billing_amount": r.headers.get("X-Openapi-Amount"), "raw": data, } def iter_translate_fast( text: str, lang: str, *, base_url: str = DEFAULT_BASE, api_key: str | None = None, poll_interval_s: float = DEFAULT_POLL_INTERVAL_S, poll_timeout_s: float = DEFAULT_POLL_TIMEOUT_S, ) -> Iterator[dict[str, Any]]: """Submit async fast translation and poll until succeeded/failed. Yields status events, then a final event with ``done=True``. """ submitted = submit_async_translate(text, lang, base_url=base_url, api_key=api_key) task_id = submitted["task_id"] yield { "done": False, "task_id": task_id, "state": submitted.get("state", "pending"), "phase": "submitted", } deadline = time.perf_counter() + poll_timeout_s while True: if time.perf_counter() > deadline: raise RuntimeError( f"async translation timed out after {poll_timeout_s:.0f}s " f"(taskId={task_id})" ) time.sleep(poll_interval_s) result = get_async_translate_result( task_id, base_url=base_url, api_key=api_key ) state = result.get("state") if state in ("succeeded", "failed"): if state == "failed": err = result.get("error") or "async translation failed" raise RuntimeError(str(err)) translated = result.get("text_translated") or "" if not str(translated).strip(): raise RuntimeError("empty translation from async result") yield { "done": True, "task_id": task_id, "state": "succeeded", "phase": "done", "text_original": text, "text_translated": translated, "translated_character_count": result.get( "translated_character_count" ), "billing_amount": result.get("billing_amount"), "raw": result.get("raw"), } return yield { "done": False, "task_id": task_id, "state": state or "pending", "phase": "polling", } def translate_fast( text: str, lang: str, *, base_url: str = DEFAULT_BASE, api_key: str | None = None, poll_interval_s: float = DEFAULT_POLL_INTERVAL_S, poll_timeout_s: float = DEFAULT_POLL_TIMEOUT_S, timeout: int | None = None, ) -> dict[str, Any]: """Async fast translate: submit + poll until complete (blocking).""" if timeout is not None: poll_timeout_s = float(timeout) final: dict[str, Any] | None = None for event in iter_translate_fast( text, lang, base_url=base_url, api_key=api_key, poll_interval_s=poll_interval_s, poll_timeout_s=poll_timeout_s, ): if event.get("done"): final = event if final is None: raise RuntimeError("async translation ended without a result") return { "state": "success", "task_id": final.get("task_id"), "text_original": final.get("text_original", text), "text_translated": final.get("text_translated", ""), "translated_character_count": final.get("translated_character_count"), "billing_amount": final.get("billing_amount"), "raw": final.get("raw"), } 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]]: """POST /translate (stream-only; do not send mode).""" with requests.post( _api_url(base_url, "/translate"), json=translate_payload(text, lang), headers=_request_headers(api_key), stream=True, timeout=timeout, ) as resp: if resp.status_code >= 400: raise RuntimeError(_http_error_message(resp)) yield from _iter_sse_json(resp) def normalize_smartdoc_markdown(markdown: str) -> str: """Fix gateway-escaped newlines without breaking LaTeX commands like \\neq.""" text = markdown text = re.sub(r"\\r\\n(?![A-Za-z])", "\n", text) text = re.sub(r"\\n(?![A-Za-z])", "\n", text) text = re.sub(r"\\r(?![A-Za-z])", "\n", text) return text def parse_document( file_path: str, *, api_key: str | None = None, output_format: str = "both", timeout: int = 300, max_bytes: int = MAX_SMARTDOC_BYTES, ) -> dict[str, Any]: """POST multipart to Smart Doc doc_parsing endpoint.""" path = Path(file_path) if not path.is_file(): raise RuntimeError(f"file not found: {file_path}") size = path.stat().st_size if size > max_bytes: raise RuntimeError( f"The file must not exceed {max_bytes // (1024 * 1024)} MB" ) key = resolve_api_key(api_key) if not key: raise RuntimeError( f"API key missing. Set {API_KEY_ENV} (or {API_KEY_ENV_FALLBACK}) " "under Space Settings → Variables / Secrets" ) with path.open("rb") as fh: files = {"file": (path.name, fh)} data = {"output_format": output_format} r = requests.post( SMARTDOC_URL, headers=_request_headers(key, json_body=False), files=files, data=data, timeout=timeout, ) try: body = r.json() except json.JSONDecodeError as exc: raise RuntimeError(f"HTTP {r.status_code}: {r.text[:500]}") from exc if r.status_code >= 400 or body.get("status") != "success": raise RuntimeError( body.get("error_msg") or body.get("message") or body.get("error") or f"HTTP {r.status_code}" ) payload = body.get("data") if isinstance(body.get("data"), dict) else body markdown = payload.get("markdown") if isinstance(payload, dict) else "" if not isinstance(markdown, str): markdown = "" results = payload.get("results") if isinstance(payload, dict) else [] if not isinstance(results, list): results = [] total_pages = payload.get("total_pages") if isinstance(payload, dict) else 0 return { "markdown": normalize_smartdoc_markdown(markdown), "results": results, "total_pages": total_pages or 0, "raw": body, }